diff --git a/main.go b/main.go index 861e76c..602fa9e 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "os/signal" "path/filepath" "reflect" + "sort" "strconv" "strings" "sync" @@ -35,7 +36,7 @@ import ( //go:embed static var embeddedStaticFiles embed.FS -const version = "1.0.4" +const version = "1.0.3" // --------------------------------------------------------------------------- // Config structs @@ -484,6 +485,59 @@ type HistoryResponse struct { Points []HistoryPoint `json:"points"` } +type HistoryPeakPoint struct { + Time string `json:"time"` + LeftPercent float64 `json:"left_percent"` + RightPercent float64 `json:"right_percent"` + TotalPercent float64 `json:"total_percent"` + TotalKN float64 `json:"total_kn"` + ImbalancePercent float64 `json:"imbalance_percent"` +} + +type HistoryAnalyticsResponse struct { + Window string `json:"window"` + From string `json:"from"` + To string `json:"to"` + SampleCount int `json:"sample_count"` + LeftAvgPct float64 `json:"left_avg_pct"` + RightAvgPct float64 `json:"right_avg_pct"` + TotalAvgPct float64 `json:"total_avg_pct"` + TotalAvgKN float64 `json:"total_avg_kn"` + ImbalanceAvgPct float64 `json:"imbalance_avg_pct"` + LeftMaxPct float64 `json:"left_max_pct"` + RightMaxPct float64 `json:"right_max_pct"` + TotalMaxPct float64 `json:"total_max_pct"` + TotalMaxKN float64 `json:"total_max_kn"` + ImbalanceMaxPct float64 `json:"imbalance_max_pct"` + LeftMinPct float64 `json:"left_min_pct"` + RightMinPct float64 `json:"right_min_pct"` + TotalMinPct float64 `json:"total_min_pct"` + ImbalanceMinPct float64 `json:"imbalance_min_pct"` + LeftStdPct float64 `json:"left_std_pct"` + RightStdPct float64 `json:"right_std_pct"` + TotalStdPct float64 `json:"total_std_pct"` + ImbalanceStdPct float64 `json:"imbalance_std_pct"` + TotalP95Pct float64 `json:"total_p95_pct"` + TotalP99Pct float64 `json:"total_p99_pct"` + ImbalanceP95Pct float64 `json:"imbalance_p95_pct"` + WarningSamples int `json:"warning_samples"` + CriticalSamples int `json:"critical_samples"` + ImbalanceWarningSamples int `json:"imbalance_warning_samples"` + ImbalanceCriticalSamples int `json:"imbalance_critical_samples"` + WarningRatePct float64 `json:"warning_rate_pct"` + CriticalRatePct float64 `json:"critical_rate_pct"` + ImbalanceWarningRatePct float64 `json:"imbalance_warning_rate_pct"` + ImbalanceCriticalRatePct float64 `json:"imbalance_critical_rate_pct"` + AlarmTransitions int `json:"alarm_transitions"` + WarningEvents int `json:"warning_events"` + CriticalEvents int `json:"critical_events"` + PLCDisconnects int `json:"plc_disconnects"` + PreviousWindowDeltaPct float64 `json:"previous_window_delta_pct"` + PreviousImbalanceDeltaPct float64 `json:"previous_imbalance_delta_pct"` + TopPeaks []HistoryPeakPoint `json:"top_peaks"` + WorstImbalances []HistoryPeakPoint `json:"worst_imbalances"` +} + type TrendResponse struct { Window string `json:"window"` AvgPeak5m float32 `json:"avg_peak_5m"` @@ -552,6 +606,50 @@ func (s NumericStats) StdDev() float64 { return math.Sqrt(v) } +type runningStats struct { + sum float64 + sumSq float64 + min float64 + max float64 + count int +} + +func (r *runningStats) Add(v float64) { + if r.count == 0 { + r.min = v + r.max = v + } else { + if v < r.min { + r.min = v + } + if v > r.max { + r.max = v + } + } + r.sum += v + r.sumSq += v * v + r.count++ +} + +func (r runningStats) Avg() float64 { + if r.count == 0 { + return 0 + } + return r.sum / float64(r.count) +} + +func (r runningStats) StdDev() float64 { + if r.count <= 1 { + return 0 + } + avg := r.Avg() + v := (r.sumSq / float64(r.count)) - (avg * avg) + if v < 0 { + v = 0 + } + return math.Sqrt(v) +} + type AlarmTracker struct { sync.Mutex PLCKnown bool @@ -2162,6 +2260,212 @@ func queryAlarmEvents(ctx context.Context, limit int) ([]AlarmEventAPI, error) { return events, rows.Err() } +func percentileFromSorted(vals []float64, p float64) float64 { + if len(vals) == 0 { + return 0 + } + if p <= 0 { + return vals[0] + } + if p >= 1 { + return vals[len(vals)-1] + } + idx := p * float64(len(vals)-1) + lo := int(math.Floor(idx)) + hi := int(math.Ceil(idx)) + if lo == hi { + return vals[lo] + } + frac := idx - float64(lo) + return vals[lo] + (vals[hi]-vals[lo])*frac +} + +func insertPeakDescending(peaks []HistoryPeakPoint, candidate HistoryPeakPoint, limit int, by func(HistoryPeakPoint) float64) []HistoryPeakPoint { + peaks = append(peaks, candidate) + sort.Slice(peaks, func(i, j int) bool { return by(peaks[i]) > by(peaks[j]) }) + if len(peaks) > limit { + peaks = peaks[:limit] + } + return peaks +} + +func queryAlarmCount(ctx context.Context, cutoffNs int64, extraWhere string, args ...any) (int, error) { + query := `SELECT COUNT(*) FROM alarm_events WHERE ts_unix_ns >= ?` + params := []any{cutoffNs} + if strings.TrimSpace(extraWhere) != "" { + query += " AND " + extraWhere + params = append(params, args...) + } + var count int + if err := db.QueryRowContext(ctx, query, params...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func queryHistoryAnalytics(ctx context.Context, window time.Duration, label string) (HistoryAnalyticsResponse, error) { + now := time.Now().UTC() + windowNs := window.Nanoseconds() + startNs := now.UnixNano() - windowNs + cfgSnap := getConfigSnapshot() + + rows, err := db.QueryContext(ctx, ` + SELECT ts_unix_ns, sila_l_pct, sila_r_pct, sum_pct, sum_kn, imbalance_pct + FROM samples + WHERE ts_unix_ns >= ? + ORDER BY ts_unix_ns ASC + `, startNs) + if err != nil { + return HistoryAnalyticsResponse{}, err + } + defer rows.Close() + + var leftStats, rightStats, totalStats, totalKNStats, imbalanceStats runningStats + totalValues := make([]float64, 0, 2048) + imbalanceValues := make([]float64, 0, 2048) + topPeaks := make([]HistoryPeakPoint, 0, 10) + worstImbalances := make([]HistoryPeakPoint, 0, 10) + warningSamples := 0 + criticalSamples := 0 + imbWarningSamples := 0 + imbCriticalSamples := 0 + firstTS := int64(0) + lastTS := int64(0) + + for rows.Next() { + var tsUnix int64 + var leftPct, rightPct, totalPct, totalKN, imbalancePct float64 + if err := rows.Scan(&tsUnix, &leftPct, &rightPct, &totalPct, &totalKN, &imbalancePct); err != nil { + return HistoryAnalyticsResponse{}, err + } + if firstTS == 0 { + firstTS = tsUnix + } + lastTS = tsUnix + leftStats.Add(leftPct) + rightStats.Add(rightPct) + totalStats.Add(totalPct) + totalKNStats.Add(totalKN) + imbalanceStats.Add(imbalancePct) + totalValues = append(totalValues, totalPct) + imbalanceValues = append(imbalanceValues, imbalancePct) + if totalPct >= cfgSnap.Thresholds.WarningPercent { + warningSamples++ + } + if totalPct >= cfgSnap.Thresholds.CriticalPercent { + criticalSamples++ + } + if imbalancePct >= cfgSnap.Thresholds.ImbalanceWarningPercent { + imbWarningSamples++ + } + if imbalancePct >= cfgSnap.Thresholds.ImbalanceCriticalPercent { + imbCriticalSamples++ + } + peak := HistoryPeakPoint{ + Time: time.Unix(0, tsUnix).Local().Format("02.01.2006 15:04:05"), + LeftPercent: leftPct, + RightPercent: rightPct, + TotalPercent: totalPct, + TotalKN: totalKN, + ImbalancePercent: imbalancePct, + } + topPeaks = insertPeakDescending(topPeaks, peak, 10, func(p HistoryPeakPoint) float64 { return p.TotalPercent }) + worstImbalances = insertPeakDescending(worstImbalances, peak, 10, func(p HistoryPeakPoint) float64 { return p.ImbalancePercent }) + } + if err := rows.Err(); err != nil { + return HistoryAnalyticsResponse{}, err + } + + sort.Float64s(totalValues) + sort.Float64s(imbalanceValues) + + warnEvents, err := queryAlarmCount(ctx, startNs, `severity = ?`, "warning") + if err != nil { + return HistoryAnalyticsResponse{}, err + } + criticalEvents, err := queryAlarmCount(ctx, startNs, `severity = ?`, "critical") + if err != nil { + return HistoryAnalyticsResponse{}, err + } + alarmTransitions, err := queryAlarmCount(ctx, startNs, ``) + if err != nil { + return HistoryAnalyticsResponse{}, err + } + plcDisconnects, err := queryAlarmCount(ctx, startNs, `source = ? AND code = ?`, "plc", "plc_disconnected") + if err != nil { + return HistoryAnalyticsResponse{}, err + } + + prevStartNs := startNs - windowNs + prevForce, err := queryNumericStats(ctx, "sum_pct", prevStartNs, startNs) + if err != nil { + return HistoryAnalyticsResponse{}, err + } + prevImb, err := queryNumericStats(ctx, "imbalance_pct", prevStartNs, startNs) + if err != nil { + return HistoryAnalyticsResponse{}, err + } + + resp := HistoryAnalyticsResponse{ + Window: label, + From: time.Unix(0, firstTS).Local().Format(time.RFC3339), + To: time.Unix(0, maxInt64(firstTS, lastTS)).Local().Format(time.RFC3339), + SampleCount: totalStats.count, + LeftAvgPct: leftStats.Avg(), + RightAvgPct: rightStats.Avg(), + TotalAvgPct: totalStats.Avg(), + TotalAvgKN: totalKNStats.Avg(), + ImbalanceAvgPct: imbalanceStats.Avg(), + LeftMaxPct: leftStats.max, + RightMaxPct: rightStats.max, + TotalMaxPct: totalStats.max, + TotalMaxKN: totalKNStats.max, + ImbalanceMaxPct: imbalanceStats.max, + LeftMinPct: leftStats.min, + RightMinPct: rightStats.min, + TotalMinPct: totalStats.min, + ImbalanceMinPct: imbalanceStats.min, + LeftStdPct: leftStats.StdDev(), + RightStdPct: rightStats.StdDev(), + TotalStdPct: totalStats.StdDev(), + ImbalanceStdPct: imbalanceStats.StdDev(), + TotalP95Pct: percentileFromSorted(totalValues, 0.95), + TotalP99Pct: percentileFromSorted(totalValues, 0.99), + ImbalanceP95Pct: percentileFromSorted(imbalanceValues, 0.95), + WarningSamples: warningSamples, + CriticalSamples: criticalSamples, + ImbalanceWarningSamples: imbWarningSamples, + ImbalanceCriticalSamples: imbCriticalSamples, + AlarmTransitions: alarmTransitions, + WarningEvents: warnEvents, + CriticalEvents: criticalEvents, + PLCDisconnects: plcDisconnects, + PreviousWindowDeltaPct: totalStats.Avg() - prevForce.Avg, + PreviousImbalanceDeltaPct: imbalanceStats.Avg() - prevImb.Avg, + TopPeaks: topPeaks, + WorstImbalances: worstImbalances, + } + if resp.SampleCount > 0 { + den := float64(resp.SampleCount) + resp.WarningRatePct = (float64(resp.WarningSamples) / den) * 100 + resp.CriticalRatePct = (float64(resp.CriticalSamples) / den) * 100 + resp.ImbalanceWarningRatePct = (float64(resp.ImbalanceWarningSamples) / den) * 100 + resp.ImbalanceCriticalRatePct = (float64(resp.ImbalanceCriticalSamples) / den) * 100 + } + if resp.SampleCount == 0 { + resp.From = time.Unix(0, startNs).Local().Format(time.RFC3339) + resp.To = now.Local().Format(time.RFC3339) + } + return resp, nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} + // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- @@ -2270,6 +2574,27 @@ func apiHistory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, HistoryResponse{Window: label, Points: points}) } +func apiHistoryAnalytics(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodGet) { + return + } + if !requireActiveLicense(w, r) { + return + } + window, label, err := parseWindow(r.URL.Query().Get("window")) + if err != nil { + http.Error(w, `{"error":"invalid window"}`, http.StatusBadRequest) + return + } + resp, err := queryHistoryAnalytics(r.Context(), window, label) + if err != nil { + log.Printf("history analytics query failed: %v", err) + http.Error(w, `{"error":"history analytics query failed"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, resp) +} + func apiTrend(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return @@ -2313,6 +2638,74 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, AlarmResponse{Events: events}) } +func serveEmbeddedHTMLPage(w http.ResponseWriter, embeddedPath string) { + data, err := embeddedStaticFiles.ReadFile(embeddedPath) + if err != nil { + log.Printf("embedded page read error (%s): %v", embeddedPath, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(data) +} + +func redirectToCanonicalPath(w http.ResponseWriter, r *http.Request, canonicalPath string) bool { + if r.URL.Path == canonicalPath { + return false + } + if r.URL.Path == canonicalPath+"/" { + http.Redirect(w, r, canonicalPath, http.StatusMovedPermanently) + return true + } + return false +} + +func serveDashboardAlias(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/dashboard" || r.URL.Path == "/dashboard/" { + http.Redirect(w, r, "/", http.StatusMovedPermanently) + return + } + http.NotFound(w, r) +} + +func serveAlarmsPage(w http.ResponseWriter, r *http.Request) { + if redirectToCanonicalPath(w, r, "/alarms") { + return + } + if r.URL.Path != "/alarms" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/alarms.html") +} + +func serveHistoryPage(w http.ResponseWriter, r *http.Request) { + if redirectToCanonicalPath(w, r, "/history") { + return + } + if r.URL.Path != "/history" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/history.html") +} + +func serveLicensePage(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/licence" || r.URL.Path == "/licence/" { + http.Redirect(w, r, "/license", http.StatusMovedPermanently) + return + } + if redirectToCanonicalPath(w, r, "/license") { + return + } + if r.URL.Path != "/license" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/license.html") +} + func serveUI(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { // Check license before serving the UI @@ -2329,6 +2722,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
Message: %s
+