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; + } -
+{{.Subtitle}}
MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}
| Time | +Severity | +Source | +Event | +Value | +Limit | +
|---|---|---|---|---|---|
| No events yet | +|||||