added stacitc pages for alarms, dashbord etc

This commit is contained in:
Dejan Rožič 2026-04-21 12:37:18 +02:00
parent 4af3ce0d88
commit bf435f9abf

407
main.go
View file

@ -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) {
<p><strong>Message:</strong> %s</p>
<p><a href="/api/license/status" style="color:#93c5fd">GET /api/license/status</a></p>
<p><a href="/api/license/request" style="color:#93c5fd">GET /api/license/request</a></p>
<p><a href="/license" style="color:#93c5fd">Open advanced license page</a></p>
<h3>Paste signed license JSON</h3>
<textarea id="licenseText" placeholder='{"app":"force_monitor",...}'></textarea>
<div style="margin-top:12px"><button onclick="activate()">Activate license</button></div>
@ -2749,12 +3143,23 @@ func main() {
}
fileServer := http.FileServer(http.FS(staticFS))
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
mux.HandleFunc("/dashboard", serveDashboardAlias)
mux.HandleFunc("/dashboard/", serveDashboardAlias)
mux.HandleFunc("/alarms", serveAlarmsPage)
mux.HandleFunc("/alarms/", serveAlarmsPage)
mux.HandleFunc("/history", serveHistoryPage)
mux.HandleFunc("/history/", serveHistoryPage)
mux.HandleFunc("/license", serveLicensePage)
mux.HandleFunc("/license/", serveLicensePage)
mux.HandleFunc("/licence", serveLicensePage)
mux.HandleFunc("/licence/", serveLicensePage)
mux.HandleFunc("/", serveUI)
mux.HandleFunc("/api/data", apiData)
mux.HandleFunc("/api/ui-revision", apiUIRevision)
mux.HandleFunc("/api/config/public", apiPublicConfig)
mux.HandleFunc("/api/history", apiHistory)
mux.HandleFunc("/api/history/analytics", apiHistoryAnalytics)
mux.HandleFunc("/api/trend", apiTrend)
mux.HandleFunc("/api/alarms", apiAlarms)