diff --git a/main.go b/main.go index 039da82..0e4ac69 100644 --- a/main.go +++ b/main.go @@ -299,6 +299,17 @@ type Sample struct { BiasPercent float32 } +type AlarmEvent struct { + TS time.Time + Severity string + Source string + Code string + State string + Message string + Value float64 + Limit float64 +} + type AppState struct { sync.RWMutex Connected bool @@ -312,6 +323,7 @@ type AppState struct { BiasPercent float32 LastUpdate time.Time DroppedSamples uint64 + DroppedEvents uint64 } type APIState struct { @@ -326,6 +338,7 @@ type APIState struct { BiasPercent float32 `json:"bias_percent"` LastUpdate string `json:"last_update"` DroppedSamples uint64 `json:"dropped_samples"` + DroppedEvents uint64 `json:"dropped_events"` } type HistoryPoint struct { @@ -339,6 +352,39 @@ type HistoryResponse struct { Points []HistoryPoint `json:"points"` } +type TrendResponse struct { + Window string `json:"window"` + AvgPeak5m float32 `json:"avg_peak_5m"` + AvgPeak1h float32 `json:"avg_peak_1h"` + AvgImbalance5m float32 `json:"avg_imbalance_5m"` + AvgImbalance1h float32 `json:"avg_imbalance_1h"` + ForceDeltaPct float32 `json:"force_delta_pct"` + ImbalanceDeltaPct float32 `json:"imbalance_delta_pct"` + ForceDirection string `json:"force_direction"` + ImbalanceDirection string `json:"imbalance_direction"` + ProcessStability string `json:"process_stability"` + StabilityReason string `json:"stability_reason"` + ForceStdDev float32 `json:"force_stddev"` + ImbalanceStdDev float32 `json:"imbalance_stddev"` + SampleCount int `json:"sample_count"` + OldHalfCount int `json:"old_half_count"` + NewHalfCount int `json:"new_half_count"` +} + +type AlarmEventAPI struct { + Time string `json:"time"` + Severity string `json:"severity"` + Source string `json:"source"` + State string `json:"state"` + Message string `json:"message"` + Value float64 `json:"value"` + Limit float64 `json:"limit"` +} + +type AlarmResponse struct { + Events []AlarmEventAPI `json:"events"` +} + type PageData struct { Title string Subtitle string @@ -354,14 +400,45 @@ type PageData struct { ImbalanceCriticalPercent float64 PollMs int DefaultWindow string + DefaultTrendWindow string +} + +type NumericStats struct { + Avg float64 + AvgSq float64 + Min float64 + Max float64 + Count int +} + +func (s NumericStats) StdDev() float64 { + if s.Count <= 1 { + return 0 + } + v := s.AvgSq - (s.Avg * s.Avg) + if v < 0 { + v = 0 + } + return math.Sqrt(v) +} + +type AlarmTracker struct { + sync.Mutex + PLCKnown bool + PLCConnected bool + LeftZone string + RightZone string + ImbZone string } var ( - cfg Config - state AppState - db *sql.DB - sampleCh chan Sample - uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) + cfg Config + state AppState + db *sql.DB + sampleCh chan Sample + alarmCh chan AlarmEvent + alarmTracker AlarmTracker + uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) ) func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) { @@ -397,13 +474,15 @@ func snapshotState() APIState { BiasPercent: state.BiasPercent, LastUpdate: lastUpdate, DroppedSamples: state.DroppedSamples, + DroppedEvents: state.DroppedEvents, } } -func setDisconnected() { +func markDisconnected(reason string) { state.Lock() state.Connected = false state.Unlock() + maybeLogPLCDisconnected(reason) } func enqueueSample(s Sample) { @@ -416,6 +495,16 @@ func enqueueSample(s Sample) { } } +func enqueueAlarm(a AlarmEvent) { + select { + case alarmCh <- a: + default: + state.Lock() + state.DroppedEvents++ + state.Unlock() + } +} + func ensureColumn(database *sql.DB, tableName, columnName, definition string) error { rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) if err != nil { @@ -481,6 +570,7 @@ func initDatabase(dbPath string) (*sql.DB, error) { CREATE TABLE IF NOT EXISTS samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts DATETIME NOT NULL, + ts_unix_ns INTEGER NOT NULL DEFAULT 0, sila_l_pct REAL NOT NULL, sila_r_pct REAL NOT NULL, sila_l_kn REAL NOT NULL, @@ -491,12 +581,31 @@ CREATE TABLE IF NOT EXISTS samples ( bias_pct REAL NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); +CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns); + +CREATE TABLE IF NOT EXISTS alarm_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts DATETIME NOT NULL, + ts_unix_ns INTEGER NOT NULL DEFAULT 0, + severity TEXT NOT NULL, + source TEXT NOT NULL, + code TEXT NOT NULL, + state TEXT NOT NULL, + message TEXT NOT NULL, + value REAL NOT NULL DEFAULT 0, + limit_value REAL NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_ns DESC); ` if _, err := database.Exec(schema); err != nil { _ = database.Close() return nil, fmt.Errorf("create schema: %w", err) } + if err := ensureColumn(database, "samples", "ts_unix_ns", "INTEGER NOT NULL DEFAULT 0"); err != nil { + _ = database.Close() + return nil, fmt.Errorf("ensure ts_unix_ns column: %w", err) + } if err := ensureColumn(database, "samples", "imbalance_pct", "REAL NOT NULL DEFAULT 0"); err != nil { _ = database.Close() return nil, fmt.Errorf("ensure imbalance_pct column: %w", err) @@ -506,6 +615,27 @@ CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); return nil, fmt.Errorf("ensure bias_pct column: %w", err) } + if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns)`); err != nil { + _ = database.Close() + return nil, fmt.Errorf("create ts_unix_ns index: %w", err) + } + + if _, err := database.Exec(` + UPDATE samples + SET ts_unix_ns = CAST(strftime('%s', ts) AS INTEGER) * 1000000000 + WHERE ts_unix_ns = 0 AND ts IS NOT NULL + `); err != nil { + log.Printf("warning: ts_unix_ns backfill failed: %v", err) + } + + if _, err := database.Exec(` + UPDATE alarm_events + SET ts_unix_ns = CAST(strftime('%s', ts) AS INTEGER) * 1000000000 + WHERE ts_unix_ns = 0 AND ts IS NOT NULL + `); err != nil { + log.Printf("warning: alarm ts_unix_ns backfill failed: %v", err) + } + return database, nil } @@ -528,9 +658,9 @@ func startDBWriter(database *sql.DB) { stmt, err := tx.Prepare(` INSERT INTO samples ( - ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, + ts, ts_unix_ns, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, sum_pct, sum_kn, imbalance_pct, bias_pct - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { _ = tx.Rollback() @@ -542,6 +672,7 @@ func startDBWriter(database *sql.DB) { for _, s := range batch { _, err := stmt.Exec( s.TS.UTC().Format(time.RFC3339Nano), + s.TS.UTC().UnixNano(), s.SilaLPct, s.SilaRPct, s.SilaLKN, @@ -585,6 +716,81 @@ func startDBWriter(database *sql.DB) { } } +func startAlarmWriter(database *sql.DB) { + ticker := time.NewTicker(1000 * time.Millisecond) + defer ticker.Stop() + + batch := make([]AlarmEvent, 0, 32) + + flush := func() { + if len(batch) == 0 { + return + } + + tx, err := database.Begin() + if err != nil { + log.Printf("alarm db begin failed: %v", err) + return + } + + stmt, err := tx.Prepare(` + INSERT INTO alarm_events ( + ts, ts_unix_ns, severity, source, code, state, message, value, limit_value + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + _ = tx.Rollback() + log.Printf("alarm db prepare failed: %v", err) + return + } + + ok := true + for _, a := range batch { + _, err := stmt.Exec( + a.TS.UTC().Format(time.RFC3339Nano), + a.TS.UTC().UnixNano(), + a.Severity, + a.Source, + a.Code, + a.State, + a.Message, + a.Value, + a.Limit, + ) + if err != nil { + ok = false + log.Printf("alarm db insert failed: %v", err) + break + } + } + + _ = stmt.Close() + + if !ok { + _ = tx.Rollback() + return + } + if err := tx.Commit(); err != nil { + log.Printf("alarm db commit failed: %v", err) + return + } + + batch = batch[:0] + } + + for { + select { + case a := <-alarmCh: + batch = append(batch, a) + if len(batch) >= 32 { + flush() + } + case <-ticker.C: + flush() + } + } +} + func startDBCleanup(database *sql.DB) { if cfg.DB.RetentionDays <= 0 { return @@ -594,9 +800,12 @@ func startDBCleanup(database *sql.DB) { 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) + cutoffNs := time.Now().AddDate(0, 0, -cfg.DB.RetentionDays).UTC().UnixNano() + if _, err := database.Exec(`DELETE FROM samples WHERE ts_unix_ns > 0 AND ts_unix_ns < ?`, cutoffNs); err != nil { + log.Printf("db cleanup samples failed: %v", err) + } + if _, err := database.Exec(`DELETE FROM alarm_events WHERE ts_unix_ns > 0 AND ts_unix_ns < ?`, cutoffNs); err != nil { + log.Printf("db cleanup alarms failed: %v", err) } } @@ -607,6 +816,188 @@ func startDBCleanup(database *sql.DB) { } } +func zoneFromValue(value float64, warn, crit float64) string { + if value >= crit { + return "critical" + } + if value >= warn { + return "warning" + } + return "ok" +} + +func sourceName(source string) string { + switch source { + case "force_left": + return "Left force" + case "force_right": + return "Right force" + case "imbalance": + return "Imbalance" + case "plc": + return "PLC" + default: + return source + } +} + +func sourceLimit(source, zone string) float64 { + switch source { + case "imbalance": + if zone == "critical" { + return cfg.Thresholds.ImbalanceCriticalPercent + } + if zone == "warning" { + return cfg.Thresholds.ImbalanceWarningPercent + } + default: + if zone == "critical" { + return cfg.Thresholds.CriticalPercent + } + if zone == "warning" { + return cfg.Thresholds.WarningPercent + } + } + return 0 +} + +func maybeLogZoneChange(source, prev, curr string, value float64) { + if prev == curr { + return + } + + name := sourceName(source) + now := time.Now() + + if prev == "" && curr == "ok" { + return + } + + switch curr { + case "ok": + enqueueAlarm(AlarmEvent{ + TS: now, + Severity: "info", + Source: source, + Code: source + "_clear", + State: "clear", + Message: fmt.Sprintf("%s returned to OK", name), + Value: value, + Limit: 0, + }) + case "warning": + msg := fmt.Sprintf("%s entered WARNING zone", name) + if prev == "critical" { + msg = fmt.Sprintf("%s downgraded from CRITICAL to WARNING", name) + } + enqueueAlarm(AlarmEvent{ + TS: now, + Severity: "warning", + Source: source, + Code: source + "_warning", + State: "active", + Message: msg, + Value: value, + Limit: sourceLimit(source, "warning"), + }) + case "critical": + msg := fmt.Sprintf("%s entered CRITICAL zone", name) + if prev == "warning" { + msg = fmt.Sprintf("%s escalated from WARNING to CRITICAL", name) + } + enqueueAlarm(AlarmEvent{ + TS: now, + Severity: "critical", + Source: source, + Code: source + "_critical", + State: "active", + Message: msg, + Value: value, + Limit: sourceLimit(source, "critical"), + }) + } +} + +func evaluateProcessAlarms(s Sample) { + leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) + rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) + imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent) + + alarmTracker.Lock() + defer alarmTracker.Unlock() + + maybeLogZoneChange("force_left", alarmTracker.LeftZone, leftZone, float64(s.SilaLPct)) + maybeLogZoneChange("force_right", alarmTracker.RightZone, rightZone, float64(s.SilaRPct)) + maybeLogZoneChange("imbalance", alarmTracker.ImbZone, imbZone, float64(s.ImbalancePercent)) + + alarmTracker.LeftZone = leftZone + alarmTracker.RightZone = rightZone + alarmTracker.ImbZone = imbZone +} + +func maybeLogPLCConnected() { + alarmTracker.Lock() + defer alarmTracker.Unlock() + + if !alarmTracker.PLCKnown { + alarmTracker.PLCKnown = true + alarmTracker.PLCConnected = true + enqueueAlarm(AlarmEvent{ + TS: time.Now(), + Severity: "info", + Source: "plc", + Code: "plc_connected", + State: "info", + Message: "PLC connection established", + Value: 1, + Limit: 0, + }) + return + } + + if !alarmTracker.PLCConnected { + alarmTracker.PLCConnected = true + enqueueAlarm(AlarmEvent{ + TS: time.Now(), + Severity: "info", + Source: "plc", + Code: "plc_restored", + State: "info", + Message: "PLC connection restored", + Value: 1, + Limit: 0, + }) + } +} + +func maybeLogPLCDisconnected(reason string) { + alarmTracker.Lock() + defer alarmTracker.Unlock() + + if !alarmTracker.PLCKnown { + return + } + if !alarmTracker.PLCConnected { + return + } + + alarmTracker.PLCConnected = false + alarmTracker.LeftZone = "" + alarmTracker.RightZone = "" + alarmTracker.ImbZone = "" + + enqueueAlarm(AlarmEvent{ + TS: time.Now(), + Severity: "critical", + Source: "plc", + Code: "plc_disconnected", + State: "active", + Message: "PLC connection lost: " + reason, + Value: 0, + Limit: 0, + }) +} + func startPLCPoller() { for { handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot) @@ -614,12 +1005,14 @@ func startPLCPoller() { handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second if err := handler.Connect(); err != nil { - setDisconnected() + markDisconnected(err.Error()) log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec) time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second) continue } + maybeLogPLCConnected() + client := gos7.NewClient(handler) log.Println("PLC connected successfully") @@ -627,7 +1020,7 @@ func startPLCPoller() { 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() + markDisconnected(err.Error()) _ = handler.Close() break } @@ -654,7 +1047,7 @@ func startPLCPoller() { state.LastUpdate = now state.Unlock() - enqueueSample(Sample{ + sample := Sample{ TS: now, SilaLPct: silaL, SilaRPct: silaR, @@ -664,7 +1057,10 @@ func startPLCPoller() { SumKN: sumKN, ImbalancePercent: imbalance, BiasPercent: bias, - }) + } + + evaluateProcessAlarms(sample) + enqueueSample(sample) time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) } @@ -702,9 +1098,9 @@ func formatHistoryLabel(t time.Time, window time.Duration) string { } func queryHistory(window time.Duration) ([]HistoryPoint, error) { - cutoff := time.Now().Add(-window).UTC().Format(time.RFC3339Nano) + cutoffNs := time.Now().Add(-window).UTC().UnixNano() - rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts >= ? ORDER BY ts ASC`, cutoff) + rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts_unix_ns >= ? ORDER BY ts_unix_ns ASC`, cutoffNs) if err != nil { return nil, err } @@ -765,6 +1161,240 @@ func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint { return out } +func validField(field string) (string, error) { + switch field { + case "sum_pct": + return "sum_pct", nil + case "imbalance_pct": + return "imbalance_pct", nil + default: + return "", fmt.Errorf("invalid field") + } +} + +func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) { + safeField, err := validField(field) + if err != nil { + return NumericStats{}, err + } + + query := fmt.Sprintf(` + SELECT + COALESCE(AVG(%[1]s), 0), + COALESCE(AVG(%[1]s * %[1]s), 0), + COALESCE(MIN(%[1]s), 0), + COALESCE(MAX(%[1]s), 0), + COUNT(*) + FROM samples + WHERE ts_unix_ns >= ? AND ts_unix_ns < ? + `, safeField) + + var stats NumericStats + err = db.QueryRow(query, fromNs, toNs).Scan(&stats.Avg, &stats.AvgSq, &stats.Min, &stats.Max, &stats.Count) + if err != nil { + return NumericStats{}, err + } + return stats, nil +} + +func classifyForceDirection(delta float64, oldCount, newCount int) string { + if oldCount < 3 || newCount < 3 { + return "insufficient_data" + } + if math.Abs(delta) < 1.0 { + return "stable" + } + if delta > 0 { + return "rising" + } + return "falling" +} + +func classifyImbalanceDirection(delta float64, oldCount, newCount int) string { + if oldCount < 3 || newCount < 3 { + return "insufficient_data" + } + if math.Abs(delta) < 0.5 { + return "stable" + } + if delta > 0 { + return "worsening" + } + return "improving" +} + +func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) { + if sampleCount < 8 { + return "insufficient_data", "Too few samples in selected trend window" + } + + if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 { + if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent { + return "unstable", "High average imbalance in last 5 minutes" + } + if math.Abs(forceDelta) >= 8.0 { + return "unstable", "Average peak force is drifting fast" + } + if forceStd >= 6.0 { + return "unstable", "Force variation is too high" + } + return "unstable", "Imbalance variation is too high" + } + + if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 { + if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent { + return "caution", "Imbalance is trending above warning region" + } + if math.Abs(forceDelta) >= 3.0 { + return "caution", "Average force is drifting" + } + if forceStd >= 3.0 { + return "caution", "Force is less repeatable than normal" + } + return "caution", "Imbalance repeatability is degrading" + } + + return "stable", "Process variation is low" +} + +func buildTrendResponse(window time.Duration, label string) (TrendResponse, error) { + nowNs := time.Now().UTC().UnixNano() + windowNs := window.Nanoseconds() + startNs := nowNs - windowNs + midNs := startNs + (windowNs / 2) + + force5m, err := queryNumericStats("sum_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs) + if err != nil { + return TrendResponse{}, err + } + force1h, err := queryNumericStats("sum_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs) + if err != nil { + return TrendResponse{}, err + } + imb5m, err := queryNumericStats("imbalance_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs) + if err != nil { + return TrendResponse{}, err + } + imb1h, err := queryNumericStats("imbalance_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs) + if err != nil { + return TrendResponse{}, err + } + + forceOld, err := queryNumericStats("sum_pct", startNs, midNs) + if err != nil { + return TrendResponse{}, err + } + forceNew, err := queryNumericStats("sum_pct", midNs, nowNs) + if err != nil { + return TrendResponse{}, err + } + imbOld, err := queryNumericStats("imbalance_pct", startNs, midNs) + if err != nil { + return TrendResponse{}, err + } + imbNew, err := queryNumericStats("imbalance_pct", midNs, nowNs) + if err != nil { + return TrendResponse{}, err + } + + forceDelta := forceNew.Avg - forceOld.Avg + imbDelta := imbNew.Avg - imbOld.Avg + forceDirection := classifyForceDirection(forceDelta, forceOld.Count, forceNew.Count) + imbDirection := classifyImbalanceDirection(imbDelta, imbOld.Count, imbNew.Count) + + fullWindowForce, err := queryNumericStats("sum_pct", startNs, nowNs) + if err != nil { + return TrendResponse{}, err + } + fullWindowImb, err := queryNumericStats("imbalance_pct", startNs, nowNs) + if err != nil { + return TrendResponse{}, err + } + + stability, reason := classifyProcessStability( + fullWindowForce.StdDev(), + fullWindowImb.StdDev(), + forceDelta, + imb5m.Avg, + fullWindowForce.Count, + ) + + return TrendResponse{ + Window: label, + AvgPeak5m: float32(force5m.Avg), + AvgPeak1h: float32(force1h.Avg), + AvgImbalance5m: float32(imb5m.Avg), + AvgImbalance1h: float32(imb1h.Avg), + ForceDeltaPct: float32(forceDelta), + ImbalanceDeltaPct: float32(imbDelta), + ForceDirection: forceDirection, + ImbalanceDirection: imbDirection, + ProcessStability: stability, + StabilityReason: reason, + ForceStdDev: float32(fullWindowForce.StdDev()), + ImbalanceStdDev: float32(fullWindowImb.StdDev()), + SampleCount: fullWindowForce.Count, + OldHalfCount: forceOld.Count, + NewHalfCount: forceNew.Count, + }, nil +} + +func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + rows, err := db.Query(` + SELECT ts, severity, source, state, message, value, limit_value + FROM alarm_events + ORDER BY ts_unix_ns DESC + LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]AlarmEventAPI, 0, limit) + for rows.Next() { + var ts string + var severity string + var source string + var state string + var message string + var value float64 + var limitValue float64 + + if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil { + return nil, err + } + + t, err := time.Parse(time.RFC3339Nano, ts) + displayTime := ts + if err == nil { + displayTime = t.Local().Format("02.01.2006 15:04:05") + } + + events = append(events, AlarmEventAPI{ + Time: displayTime, + Severity: severity, + Source: source, + State: state, + Message: message, + Value: value, + Limit: limitValue, + }) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return events, nil +} + func apiData(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -793,6 +1423,47 @@ func apiHistory(w http.ResponseWriter, r *http.Request) { }) } +func apiTrend(w http.ResponseWriter, r *http.Request) { + window, label, err := parseWindow(r.URL.Query().Get("window")) + if err != nil { + http.Error(w, "invalid trend window", http.StatusBadRequest) + return + } + + resp, err := buildTrendResponse(window, label) + if err != nil { + http.Error(w, "trend query failed", http.StatusInternalServerError) + log.Printf("trend query failed: %v", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func apiAlarms(w http.ResponseWriter, r *http.Request) { + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + limit = n + } + } + + events, err := queryAlarmEvents(limit) + if err != nil { + http.Error(w, "alarm query failed", http.StatusInternalServerError) + log.Printf("alarm query failed: %v", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(AlarmResponse{ + Events: events, + }) +} + func serveUI(w http.ResponseWriter, r *http.Request) { data := PageData{ Title: cfg.UI.Title, @@ -809,6 +1480,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) { ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent, PollMs: cfg.PLC.PollMs, DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes), + DefaultTrendWindow: "15m", } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -842,6 +1514,7 @@ func main() { defer db.Close() sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) + alarmCh = make(chan AlarmEvent, 512) log.Printf("config loaded from: %s", configPath) log.Printf("sqlite db: %s", dbPath) @@ -850,17 +1523,19 @@ func main() { log.Printf("Press MAX_TONNAGE: %.2f %s", cfg.Press.MAX_TONNAGE, cfg.UI.UnitForce) go startDBWriter(db) + go startAlarmWriter(db) go startDBCleanup(db) go startPLCPoller() - // Serve embedded static assets (tailwind.min.js, chart.umd.min.js) http.Handle("/static/", http.FileServer(http.FS(staticFiles))) http.HandleFunc("/", serveUI) http.HandleFunc("/api/data", apiData) http.HandleFunc("/api/history", apiHistory) + http.HandleFunc("/api/trend", apiTrend) + http.HandleFunc("/api/alarms", apiAlarms) log.Println("S7-1200 Force Monitor started") - log.Println("VERSION 0.4.1") + log.Println("VERSION 0.7.0") log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) } @@ -878,17 +1553,40 @@ const uiHTML = ` --bg1: #050816; --bg2: #0b1224; --panel: rgba(255,255,255,0.055); + --body-text: #f4f4f5; + --button-bg: rgba(255,255,255,0.05); + --button-border: rgba(255,255,255,0.10); + --button-text: #e4e4e7; } * { box-sizing: border-box; } + html, body { + min-height: 100%; + } + body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%), radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%), linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%); - color: #f4f4f5; + color: var(--body-text); + transition: background 180ms ease, color 180ms ease; + } + + body[data-theme="light"] { + --bg1: #eef4ff; + --bg2: #f8fafc; + --panel: rgba(255,255,255,0.80); + --body-text: #0f172a; + --button-bg: rgba(255,255,255,0.88); + --button-border: rgba(15,23,42,0.10); + --button-text: #0f172a; + background: + radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%), + radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%), + linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%); } .glass { @@ -897,6 +1595,98 @@ const uiHTML = ` -webkit-backdrop-filter: blur(14px); } + body[data-theme="light"] .glass, + body[data-theme="light"] .summary-card, + body[data-theme="light"] .intel-card, + body[data-theme="light"] .verdict-card { + border-color: rgba(15,23,42,0.10) !important; + box-shadow: 0 10px 28px rgba(15,23,42,0.06); + } + + body[data-theme="light"] .bg-zinc-900\/60, + body[data-theme="light"] .bg-white\/5 { + background: rgba(255,255,255,0.86) !important; + } + + body[data-theme="light"] .border-zinc-800, + body[data-theme="light"] .border-white\/10 { + border-color: rgba(15,23,42,0.10) !important; + } + + body[data-theme="light"] .text-zinc-100, + body[data-theme="light"] .text-zinc-200 { + color: #0f172a !important; + } + + body[data-theme="light"] .text-zinc-300 { + color: #1e293b !important; + } + + body[data-theme="light"] .text-zinc-400, + body[data-theme="light"] .text-zinc-500 { + color: #475569 !important; + } + + body[data-theme="light"] .text-emerald-300 { + color: #059669 !important; + } + + body[data-theme="light"] .text-emerald-400 { + color: #10b981 !important; + } + + body[data-theme="light"] .text-sky-100, + body[data-theme="light"] .text-sky-200 { + color: #0369a1 !important; + } + + body[data-theme="light"] .text-violet-100, + body[data-theme="light"] .text-violet-200 { + color: #7c3aed !important; + } + + body[data-theme="light"] .text-amber-200 { + color: #b45309 !important; + } + + body[data-theme="light"] .text-sky-400 { + color: #0284c7 !important; + } + + body[data-theme="light"] .text-violet-400 { + color: #7c3aed !important; + } + + body[data-theme="light"] .text-red-400 { + color: #dc2626 !important; + } + + body[data-theme="light"] .text-yellow-400 { + color: #b45309 !important; + } + + .control-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 10px 14px; + border-radius: 16px; + border: 1px solid var(--button-border); + background: var(--button-bg); + color: var(--button-text); + font-weight: 600; + transition: 160ms ease; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + + .control-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(0,0,0,0.10); + } + .soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); } .soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); } .soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); } @@ -915,19 +1705,28 @@ const uiHTML = ` display: block; } - .window-btn.active { + .window-btn.active, + .trend-window-btn.active { border-color: rgba(34,211,238,0.9); color: white; background: rgba(34,211,238,0.14); box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset; } + body[data-theme="light"] .window-btn.active, + body[data-theme="light"] .trend-window-btn.active { + color: #0f172a; + background: rgba(14,165,233,0.12); + } + .chart-wrap { width: min(92vw, 1800px); margin: 0 auto; } - .summary-card { + .summary-card, + .intel-card, + .verdict-card { display: flex; align-items: center; justify-content: space-between; @@ -939,22 +1738,35 @@ const uiHTML = ` transition: 180ms ease; } - .summary-card.ok { + .intel-card { + min-height: 126px; + align-items: flex-start; + } + + .summary-card.ok, + .intel-card.ok, + .verdict-card.ok { border-color: rgba(34,197,94,0.35); box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset, 0 0 26px rgba(34,197,94,0.06); } - .summary-card.warning { + .summary-card.warning, + .intel-card.warning, + .verdict-card.warning { border-color: rgba(234,179,8,0.35); box-shadow: 0 0 0 1px rgba(234,179,8,0.08) inset, 0 0 26px rgba(234,179,8,0.06); } - .summary-card.critical { + .summary-card.critical, + .intel-card.critical, + .verdict-card.critical { border-color: rgba(239,68,68,0.35); box-shadow: 0 0 0 1px rgba(239,68,68,0.08) inset, 0 0 26px rgba(239,68,68,0.06); } - .summary-card.neutral { + .summary-card.neutral, + .intel-card.neutral, + .verdict-card.neutral { border-color: rgba(113,113,122,0.35); box-shadow: 0 0 0 1px rgba(113,113,122,0.08) inset; } @@ -989,9 +1801,98 @@ const uiHTML = ` .summary-status.warning { color: #facc15; } .summary-status.critical { color: #f87171; } .summary-status.neutral { color: #a1a1aa; } + + .intel-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: #f4f4f5; + } + + body[data-theme="light"] .intel-value { + color: #0f172a; + } + + .intel-sub { + font-size: 0.83rem; + color: #a1a1aa; + margin-top: 10px; + line-height: 1.35; + } + + .intel-kpi { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 1.1rem; + font-weight: 700; + } + + .dir-up { color: #facc15; } + .dir-down { color: #34d399; } + .dir-flat { color: #a1a1aa; } + .dir-bad { color: #f87171; } + + .mini-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + } + + .severity-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 86px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + } + + .severity-pill.info { + background: rgba(59,130,246,0.12); + color: #93c5fd; + border: 1px solid rgba(59,130,246,0.22); + } + + .severity-pill.warning { + background: rgba(245,158,11,0.12); + color: #fde68a; + border: 1px solid rgba(245,158,11,0.22); + } + + .severity-pill.critical { + background: rgba(239,68,68,0.12); + color: #fca5a5; + border: 1px solid rgba(239,68,68,0.22); + } + + body[data-theme="light"] .severity-pill.info { + color: #1d4ed8; + } + + body[data-theme="light"] .severity-pill.warning { + color: #b45309; + } + + body[data-theme="light"] .severity-pill.critical { + color: #dc2626; + } + + .alarm-table tbody tr:hover { + background: rgba(255,255,255,0.03); + } + + body[data-theme="light"] .alarm-table tbody tr:hover { + background: rgba(15,23,42,0.03); + } + + .limit-line-note { + font-size: 0.8rem; + color: #a1a1aa; + } - +
-
+

{{.Title}}

{{.Subtitle}}

MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}

-
-
-
- Disconnected +
+
+ + +
+ +
+
+
+ Disconnected +
+ +
Last update: --:--:--.---
+ +
Dropped S: 0 | E: 0
- -
Last update: --:--:--.---
- -
Dropped queue: 0
+
+
+
Machine verdict
+
NO DATA
+
+
Waiting for PLC data
+
+
@@ -1091,6 +2007,98 @@ const uiHTML = `
+
+
+
+

Drift / Deterioration Intelligence

+
Averages, drift direction, imbalance deterioration and process stability
+
+ +
+ + + + +
+ + +
+
+
+ +
+
+
+
AVG PEAK 5 MIN
+
--
+
No data
+
+
+ +
+
+
AVG PEAK 1 HOUR
+
--
+
No data
+
+
+ +
+
+
FORCE TREND
+
--
+
No data
+
+
+ +
+
+
IMBALANCE TREND
+
--
+
No data
+
+
+ +
+
+
PROCESS STABILITY
+
--
+
No data
+
+
+
+
+ +
+
+
+

Event / Alarm Timeline

+
Recent transitions show exactly when the process began drifting, overloading, losing balance, or losing PLC communication
+
+
Newest events first • clear events included
+
+ +
+ + + + + + + + + + + + + + + + +
TimeSeveritySourceEventValueLimit
No events yet
+
+
+
@@ -1142,7 +2150,7 @@ const uiHTML = `

Peak Trend

-
Piezo peak/stroke history from SQLite
+
Piezo peak/stroke history from SQLite with visible warning and critical limits
@@ -1177,6 +2185,8 @@ const uiHTML = ` const UNIT_PCT = '{{.UnitPct}}'; const POLL_MS = {{.PollMs}}; const DEFAULT_WINDOW = '{{.DefaultWindow}}'; + const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}'; + const STALE_MS = Math.max(POLL_MS * 4, 2500); const START_ANGLE = Math.PI * 0.75; const END_ANGLE = Math.PI * 2.25; @@ -1184,11 +2194,28 @@ const uiHTML = ` let lineChart = null; let latestData = null; let currentWindow = DEFAULT_WINDOW; + let currentTrendWindow = DEFAULT_TREND_WINDOW; + let currentTheme = 'dark'; let historyBusy = false; + let trendBusy = false; + let alarmsBusy = false; function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function lerp(a, b, t) { return a + (b - a) * t; } + function isLightTheme() { + return currentTheme === 'light'; + } + + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + function colorMix(c1, c2, t) { return { r: Math.round(lerp(c1.r, c2.r, t)), @@ -1276,7 +2303,11 @@ const uiHTML = ` const canvas = document.getElementById(canvasId); if (!canvas) return; - const { ctx, w, h } = prepCanvas(canvas); + const prep = prepCanvas(canvas); + const ctx = prep.ctx; + const w = prep.w; + const h = prep.h; + const light = isLightTheme(); const cx = w / 2; const cy = h * 0.57; @@ -1288,16 +2319,16 @@ const uiHTML = ` 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.45)'; + ctx.fillStyle = light ? 'rgba(15,23,42,0.04)' : 'rgba(255,255,255,0.015)'; + ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 30; ctx.fill(); ctx.restore(); - drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, 'rgba(255,255,255,0.06)', trackWidth + 10, 0); + drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, light ? 'rgba(15,23,42,0.08)' : 'rgba(255,255,255,0.06)', trackWidth + 10, 0); drawColoredBand(ctx, cx, cy, radius, trackWidth); - drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, '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); + drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, light ? 'rgba(255,255,255,0.72)' : 'rgba(9,9,11,0.60)', trackWidth - 1, 0); + drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, light ? 'rgba(15,23,42,0.05)' : 'rgba(255,255,255,0.04)', trackWidth - 1, 10); for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) { const a = valueToAngle(v); @@ -1315,13 +2346,13 @@ const uiHTML = ` ctx.lineTo(p2.x, p2.y); if (isThreshold) { - ctx.strokeStyle = '#ffffff'; + ctx.strokeStyle = light ? '#0f172a' : '#ffffff'; ctx.lineWidth = 3.2; } else if (isMajor) { - ctx.strokeStyle = 'rgba(255,255,255,0.86)'; + ctx.strokeStyle = light ? 'rgba(15,23,42,0.80)' : 'rgba(255,255,255,0.86)'; ctx.lineWidth = 2.2; } else { - ctx.strokeStyle = 'rgba(161,161,170,0.74)'; + ctx.strokeStyle = light ? 'rgba(71,85,105,0.65)' : 'rgba(161,161,170,0.74)'; ctx.lineWidth = 1.1; } ctx.stroke(); @@ -1330,7 +2361,7 @@ const uiHTML = ` 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.fillStyle = light ? 'rgba(15,23,42,0.88)' : 'rgba(244,244,245,0.96)'; ctx.font = '700 18px system-ui, sans-serif'; for (const v of labels) { @@ -1351,15 +2382,15 @@ const uiHTML = ` 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.fillStyle = light ? '#0f172a' : '#ffffff'; + ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.18)'; ctx.shadowBlur = 10; ctx.fill(); ctx.restore(); ctx.beginPath(); ctx.arc(cx, cy, 14, 0, Math.PI * 2); - ctx.fillStyle = '#101114'; + ctx.fillStyle = light ? '#ffffff' : '#101114'; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = sideAccent; @@ -1367,17 +2398,17 @@ const uiHTML = ` ctx.beginPath(); ctx.arc(cx, cy, 4.5, 0, Math.PI * 2); - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = light ? '#0f172a' : '#ffffff'; ctx.fill(); const majorTickInner = radius * 0.72; const centerPlateRadius = majorTickInner - 18; ctx.beginPath(); ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(9,9,11,0.90)'; + ctx.fillStyle = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.90)'; ctx.fill(); ctx.lineWidth = 1.2; - ctx.strokeStyle = 'rgba(255,255,255,0.10)'; + ctx.strokeStyle = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.10)'; ctx.stroke(); const valueText = value.toFixed(1); @@ -1388,7 +2419,7 @@ const uiHTML = ` ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = light ? '#0f172a' : '#ffffff'; ctx.font = '700 ' + valueFontPx + 'px system-ui, sans-serif'; ctx.fillText(valueText, cx, cy - 6); @@ -1396,7 +2427,7 @@ const uiHTML = ` ctx.font = '700 18px system-ui, sans-serif'; ctx.fillText(UNIT_PCT, cx, cy + 28); - ctx.fillStyle = '#a1a1aa'; + ctx.fillStyle = light ? '#334155' : '#a1a1aa'; ctx.font = '600 16px system-ui, sans-serif'; ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 54); } @@ -1413,19 +2444,27 @@ const uiHTML = ` return 'ok'; } - function setStatusConnected(connected) { + function setConnectionIndicator(connected, stale) { 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 { + if (!connected) { dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20'; text.textContent = 'Disconnected'; text.className = 'font-semibold text-lg text-red-400'; + return; } + + if (stale) { + dot.className = 'w-4 h-4 rounded-full bg-yellow-400 ring-4 ring-yellow-400/20'; + text.textContent = 'Stale'; + text.className = 'font-semibold text-lg text-yellow-300'; + return; + } + + dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20'; + text.textContent = 'Connected'; + text.className = 'font-semibold text-lg text-emerald-400'; } function applyChannelState(side, percentValue) { @@ -1480,7 +2519,16 @@ const uiHTML = ` val.textContent = value; } - function updateSummaryBar(connected, leftPercent, rightPercent, imbalance) { + function setVerdict(zone, statusText, reasonText) { + const card = document.getElementById('verdict-card'); + const status = document.getElementById('verdict-status'); + const reason = document.getElementById('verdict-reason'); + card.className = 'verdict-card ' + zone; + status.textContent = statusText; + reason.textContent = reasonText; + } + + function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) { if (!connected) { setSummaryCard('force', 'neutral', 'NO DATA', '--'); setSummaryCard('imbalance', 'neutral', 'NO DATA', '--'); @@ -1498,15 +2546,66 @@ const uiHTML = ` const imbalanceText = imbalanceZone === 'ok' ? 'OK' : imbalanceZone === 'warning' ? 'WARNING' : 'CRITICAL'; setSummaryCard('imbalance', imbalanceZone, imbalanceText, imbalance.toFixed(1) + UNIT_PCT); - setSummaryCard('plc', 'ok', 'OK', 'Online'); + if (stale) { + setSummaryCard('plc', 'warning', 'STALE', 'No fresh data'); + } else { + setSummaryCard('plc', 'ok', 'OK', 'Online'); + } } - function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected) { + function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) { + if (!connected) { + setVerdict('critical', 'OFFLINE', 'No PLC communication'); + return; + } + + if (stale) { + setVerdict('warning', 'STALE DATA', 'PLC connected but no fresh values received'); + return; + } + + const leftCritical = leftPercent >= CRITICAL_PERCENT; + const rightCritical = rightPercent >= CRITICAL_PERCENT; + const imbCritical = imbalance >= IMBALANCE_CRITICAL_PERCENT; + + if (leftCritical || rightCritical || imbCritical) { + const reasons = []; + if (leftCritical) reasons.push('left force critical'); + if (rightCritical) reasons.push('right force critical'); + if (imbCritical) reasons.push('imbalance critical'); + setVerdict('critical', 'CRITICAL', reasons.join(' • ')); + return; + } + + const leftWarning = leftPercent >= WARNING_PERCENT; + const rightWarning = rightPercent >= WARNING_PERCENT; + const imbWarning = imbalance >= IMBALANCE_WARNING_PERCENT; + + if (leftWarning || rightWarning || imbWarning) { + const reasons = []; + if (leftWarning) reasons.push('left force warning'); + if (rightWarning) reasons.push('right force warning'); + if (imbWarning) reasons.push('imbalance warning'); + setVerdict('warning', 'WARNING', reasons.join(' • ')); + return; + } + + setVerdict('ok', 'OK', 'Production stable within configured force and imbalance limits'); + } + + function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) { const banner = document.getElementById('alarm-banner'); const text = document.getElementById('alarm-text'); if (!connected) { - banner.classList.add('hidden'); + text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE'; + banner.classList.remove('hidden'); + return; + } + + if (stale) { + text.textContent = 'WARNING • PLC DATA STALE'; + banner.classList.remove('hidden'); return; } @@ -1532,7 +2631,7 @@ const uiHTML = ` parts.push('IMBALANCE'); } - text.textContent = 'CRITICAL ALARM ACTIVE \u2022 ' + parts.join(' \u2022 '); + text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • '); banner.classList.remove('hidden'); } @@ -1548,6 +2647,145 @@ const uiHTML = ` drawGauge('gaugeR', rightPercent, rightKN, '#c084fc'); } + function directionLabel(direction) { + if (direction === 'rising') return '↑ rising'; + if (direction === 'falling') return '↓ falling'; + if (direction === 'worsening') return '↑ worsening'; + if (direction === 'improving') return '↓ improving'; + if (direction === 'stable') return '→ stable'; + return 'No data'; + } + + function directionClass(direction) { + if (direction === 'rising') return 'dir-up'; + if (direction === 'falling') return 'dir-down'; + if (direction === 'worsening') return 'dir-bad'; + if (direction === 'improving') return 'dir-down'; + if (direction === 'stable') return 'dir-flat'; + return 'dir-flat'; + } + + function trendZoneForForce(direction, delta) { + const abs = Math.abs(delta); + if (direction === 'insufficient_data') return 'neutral'; + if (direction === 'stable') return 'ok'; + if (direction === 'rising') return abs >= 8 ? 'critical' : 'warning'; + return 'ok'; + } + + function trendZoneForImbalance(direction, delta) { + const abs = Math.abs(delta); + if (direction === 'insufficient_data') return 'neutral'; + if (direction === 'stable') return 'ok'; + if (direction === 'worsening') return abs >= 4 ? 'critical' : 'warning'; + return 'ok'; + } + + function stabilityZone(stability) { + if (stability === 'stable') return 'ok'; + if (stability === 'caution') return 'warning'; + if (stability === 'unstable') return 'critical'; + return 'neutral'; + } + + function setIntelCard(idPrefix, zone, valueText, subText) { + const card = document.getElementById(idPrefix + '-card'); + const value = document.getElementById(idPrefix + '-value'); + const sub = document.getElementById(idPrefix + '-sub'); + card.className = 'intel-card ' + zone; + value.innerHTML = valueText; + sub.innerHTML = subText; + } + + function formatSource(source) { + if (source === 'force_left') return 'LEFT'; + if (source === 'force_right') return 'RIGHT'; + if (source === 'imbalance') return 'IMBALANCE'; + if (source === 'plc') return 'PLC'; + return String(source || '').toUpperCase(); + } + + function formatValue(value) { + const n = Number(value); + if (!isFinite(n)) return '--'; + return n.toFixed(1) + UNIT_PCT; + } + + function applyTheme(theme) { + currentTheme = theme === 'light' ? 'light' : 'dark'; + document.body.setAttribute('data-theme', currentTheme); + try { + localStorage.setItem('force-monitor-theme', currentTheme); + } catch (e) {} + updateThemeButton(); + updateChartTheme(); + redrawGauges(); + } + + function initTheme() { + let theme = 'dark'; + try { + const stored = localStorage.getItem('force-monitor-theme'); + if (stored === 'light' || stored === 'dark') { + theme = stored; + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + theme = 'light'; + } + } catch (e) {} + applyTheme(theme); + } + + function toggleTheme() { + applyTheme(isLightTheme() ? 'dark' : 'light'); + } + + function updateThemeButton() { + const btn = document.getElementById('theme-toggle'); + if (!btn) return; + btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme'; + } + + function updateFullscreenButton() { + const btn = document.getElementById('fullscreen-toggle'); + if (!btn) return; + btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen'; + } + + async function toggleFullscreen() { + try { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } else { + await document.exitFullscreen(); + } + } catch (err) { + console.warn('Fullscreen error:', err); + } finally { + updateFullscreenButton(); + } + } + + function updateChartTheme() { + if (!lineChart) return; + + const light = isLightTheme(); + const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)'; + const tick = light ? '#334155' : '#a1a1aa'; + const legend = light ? '#0f172a' : '#f4f4f5'; + const tooltipBg = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.96)'; + const tooltipText = light ? '#0f172a' : '#f4f4f5'; + + lineChart.options.scales.x.grid.color = grid; + lineChart.options.scales.x.ticks.color = tick; + lineChart.options.scales.y.grid.color = grid; + lineChart.options.scales.y.ticks.color = tick; + lineChart.options.plugins.legend.labels.color = legend; + lineChart.options.plugins.tooltip.backgroundColor = tooltipBg; + lineChart.options.plugins.tooltip.titleColor = tooltipText; + lineChart.options.plugins.tooltip.bodyColor = tooltipText; + lineChart.update('none'); + } + async function fetchLiveData() { try { const res = await fetch('/api/data', { cache: 'no-store' }); @@ -1564,7 +2802,15 @@ const uiHTML = ` const imbalance = Number(d.imbalance_percent) || 0; const bias = Number(d.bias_percent) || 0; - setStatusConnected(connected); + let stale = false; + if (connected && d.last_update) { + const lastTs = new Date(d.last_update).getTime(); + if (!isNaN(lastTs)) { + stale = (Date.now() - lastTs) > STALE_MS; + } + } + + setConnectionIndicator(connected, stale); document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1); document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE; @@ -1578,16 +2824,20 @@ const uiHTML = ` 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); + document.getElementById('dropped-events').textContent = String(d.dropped_events || 0); applyChannelState('l', leftPercent); applyChannelState('r', rightPercent); - updateSummaryBar(connected, leftPercent, rightPercent, imbalance); - updateAlarmBanner(leftPercent, rightPercent, imbalance, connected); + updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance); + updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance); + updateAlarmBanner(leftPercent, rightPercent, imbalance, connected, stale); redrawGauges(); } catch (err) { console.warn('Live fetch error:', err); - setStatusConnected(false); - updateSummaryBar(false, 0, 0, 0); + setConnectionIndicator(false, false); + updateSummaryBar(false, false, 0, 0, 0); + updateMachineVerdict(false, false, 0, 0, 0); + updateAlarmBanner(0, 0, 0, false, false); } } @@ -1604,10 +2854,14 @@ const uiHTML = ` const labels = pts.map(p => p.time); const dataL = pts.map(p => p.sila_l); const dataR = pts.map(p => p.sila_r); + const warnLine = labels.map(() => WARNING_PERCENT); + const critLine = labels.map(() => CRITICAL_PERCENT); lineChart.data.labels = labels; lineChart.data.datasets[0].data = dataL; lineChart.data.datasets[1].data = dataR; + lineChart.data.datasets[2].data = warnLine; + lineChart.data.datasets[3].data = critLine; lineChart.update('none'); } catch (err) { console.warn('History fetch error:', err); @@ -1616,25 +2870,158 @@ const uiHTML = ` } } + async function fetchTrend() { + if (trendBusy) return; + trendBusy = true; + + try { + const res = await fetch('/api/trend?window=' + encodeURIComponent(currentTrendWindow), { cache: 'no-store' }); + if (!res.ok) throw new Error('Trend request failed'); + const d = await res.json(); + + const avgPeak5m = Number(d.avg_peak_5m) || 0; + const avgPeak1h = Number(d.avg_peak_1h) || 0; + const avgImb5m = Number(d.avg_imbalance_5m) || 0; + const avgImb1h = Number(d.avg_imbalance_1h) || 0; + const forceDelta = Number(d.force_delta_pct) || 0; + const imbDelta = Number(d.imbalance_delta_pct) || 0; + const forceDir = d.force_direction || 'insufficient_data'; + const imbDir = d.imbalance_direction || 'insufficient_data'; + const stability = d.process_stability || 'insufficient_data'; + const stabilityReason = d.stability_reason || 'No data'; + const forceStd = Number(d.force_stddev) || 0; + const imbStd = Number(d.imbalance_stddev) || 0; + const sampleCount = Number(d.sample_count) || 0; + const windowLabel = d.window || currentTrendWindow; + + setIntelCard( + 'intel-avg5', + getZone(avgPeak5m), + avgPeak5m.toFixed(1) + UNIT_PCT, + 'Avg imbalance 5m: ' + avgImb5m.toFixed(1) + UNIT_PCT + '' + ); + + setIntelCard( + 'intel-avg1h', + getZone(avgPeak1h), + avgPeak1h.toFixed(1) + UNIT_PCT, + 'Avg imbalance 1h: ' + avgImb1h.toFixed(1) + UNIT_PCT + '' + ); + + setIntelCard( + 'intel-force', + trendZoneForForce(forceDir, forceDelta), + (forceDelta >= 0 ? '+' : '') + forceDelta.toFixed(1) + UNIT_PCT, + '' + directionLabel(forceDir) + '
Δ avg force over ' + windowLabel + ' • σ ' + forceStd.toFixed(2) + ); + + setIntelCard( + 'intel-imb', + trendZoneForImbalance(imbDir, imbDelta), + (imbDelta >= 0 ? '+' : '') + imbDelta.toFixed(1) + UNIT_PCT, + '' + directionLabel(imbDir) + '
Δ avg imbalance over ' + windowLabel + ' • σ ' + imbStd.toFixed(2) + ); + + setIntelCard( + 'intel-stability', + stabilityZone(stability), + String(stability).toUpperCase(), + stabilityReason + '
Samples: ' + sampleCount + ' • Window: ' + windowLabel + '' + ); + } catch (err) { + console.warn('Trend fetch error:', err); + setIntelCard('intel-avg5', 'neutral', '--', 'No data'); + setIntelCard('intel-avg1h', 'neutral', '--', 'No data'); + setIntelCard('intel-force', 'neutral', '--', 'No data'); + setIntelCard('intel-imb', 'neutral', '--', 'No data'); + setIntelCard('intel-stability', 'neutral', '--', 'No data'); + } finally { + trendBusy = false; + } + } + + async function fetchAlarms() { + if (alarmsBusy) return; + alarmsBusy = true; + + try { + const res = await fetch('/api/alarms?limit=20', { cache: 'no-store' }); + if (!res.ok) throw new Error('Alarm request failed'); + const d = await res.json(); + const events = Array.isArray(d.events) ? d.events : []; + const body = document.getElementById('alarm-table-body'); + + if (events.length === 0) { + body.innerHTML = 'No events yet'; + return; + } + + let html = ''; + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + const sev = String(ev.severity || 'info'); + const val = ev.source === 'plc' ? '--' : formatValue(ev.value); + const lim = ev.limit > 0 ? formatValue(ev.limit) : '--'; + + html += ''; + html += '' + escapeHtml(ev.time || '--') + ''; + html += '' + escapeHtml(sev.toUpperCase()) + ''; + html += '' + escapeHtml(formatSource(ev.source)) + ''; + html += '' + escapeHtml(ev.message || '--') + ''; + html += '' + escapeHtml(val) + ''; + html += '' + escapeHtml(lim) + ''; + html += ''; + } + + body.innerHTML = html; + } catch (err) { + console.warn('Alarm fetch error:', err); + } finally { + alarmsBusy = false; + } + } + function setActiveWindowButton(value) { document.querySelectorAll('.window-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.window === value); }); } + function setActiveTrendWindowButton(value) { + document.querySelectorAll('.trend-window-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.window === value); + }); + } + function useWindow(value) { currentWindow = value; setActiveWindowButton(value); fetchHistory(); } + function useTrendWindow(value) { + currentTrendWindow = value; + setActiveTrendWindowButton(value); + fetchTrend(); + } + window.onload = () => { + initTheme(); + setActiveWindowButton(DEFAULT_WINDOW); + setActiveTrendWindowButton(DEFAULT_TREND_WINDOW); + + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + document.getElementById('fullscreen-toggle').addEventListener('click', toggleFullscreen); document.querySelectorAll('.window-btn').forEach(btn => { btn.addEventListener('click', () => useWindow(btn.dataset.window)); }); + document.querySelectorAll('.trend-window-btn').forEach(btn => { + btn.addEventListener('click', () => useTrendWindow(btn.dataset.window)); + }); + document.getElementById('apply-window').addEventListener('click', () => { const val = document.getElementById('custom-window').value.trim(); if (!val) return; @@ -1643,6 +3030,17 @@ const uiHTML = ` fetchHistory(); }); + document.getElementById('apply-trend-window').addEventListener('click', () => { + const val = document.getElementById('custom-trend-window').value.trim(); + if (!val) return; + currentTrendWindow = val; + document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active')); + fetchTrend(); + }); + + document.addEventListener('fullscreenchange', updateFullscreenButton); + updateFullscreenButton(); + lineChart = new Chart(document.getElementById('lineChart'), { type: 'line', data: { @@ -1665,6 +3063,24 @@ const uiHTML = ` tension: 0.22, pointRadius: 0, data: [] + }, + { + label: 'Warning limit', + borderColor: 'rgba(245,158,11,0.95)', + borderWidth: 2, + pointRadius: 0, + borderDash: [8, 6], + tension: 0, + data: [] + }, + { + label: 'Critical limit', + borderColor: 'rgba(239,68,68,0.95)', + borderWidth: 2, + pointRadius: 0, + borderDash: [8, 6], + tension: 0, + data: [] } ] }, @@ -1687,16 +3103,26 @@ const uiHTML = ` }, plugins: { legend: { position: 'top', labels: { color: '#f4f4f5' } }, - tooltip: { backgroundColor: 'rgba(9,9,11,0.96)' } + tooltip: { + backgroundColor: 'rgba(9,9,11,0.96)', + titleColor: '#f4f4f5', + bodyColor: '#f4f4f5' + } } } }); + updateChartTheme(); + fetchLiveData(); fetchHistory(); + fetchTrend(); + fetchAlarms(); setInterval(fetchLiveData, POLL_MS); setInterval(fetchHistory, Math.max(1500, POLL_MS * 3)); + setInterval(fetchTrend, Math.max(2500, POLL_MS * 5)); + setInterval(fetchAlarms, 2500); window.addEventListener('resize', redrawGauges); };