From 6e2cf09ce52c557dd7ca66ef8e1bd2970bc87004 Mon Sep 17 00:00:00 2001 From: Dejan R Date: Wed, 22 Apr 2026 10:42:52 +0200 Subject: [PATCH] added for new version --- licence.go | 644 --------------------------------- main.go | 570 ++++++++++++++++++++++++++++- static/alarms.html | 9 +- static/app-common.js | 92 +++++ static/history.html | 11 +- static/index.html | 28 +- static/kiosk.html | 103 ++++++ static/license.html | 22 +- static/process-capability.html | 29 ++ static/reports.html | 25 ++ 10 files changed, 866 insertions(+), 667 deletions(-) delete mode 100644 licence.go create mode 100644 static/app-common.js create mode 100644 static/kiosk.html create mode 100644 static/process-capability.html create mode 100644 static/reports.html diff --git a/licence.go b/licence.go deleted file mode 100644 index 91ca728..0000000 --- a/licence.go +++ /dev/null @@ -1,644 +0,0 @@ -package main - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "time" -) - -// --------------------------------------------------------------------------- -// License config and public status types -// --------------------------------------------------------------------------- - -type LicenseConfig struct { - Enabled bool `yaml:"enabled"` - TrialDays int `yaml:"trial_days"` - RequireAfterTrial bool `yaml:"require_after_trial"` - DataDir string `yaml:"data_dir"` - PublicKeyBase64 string `yaml:"public_key_base64"` - ProductCode string `yaml:"product_code"` -} - -type ActivationRequest struct { - App string `json:"app"` - Version string `json:"version"` - GeneratedAt string `json:"generated_at"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - Fingerprint string `json:"fingerprint"` - FingerprintShort string `json:"fingerprint_short"` - Components []string `json:"components"` -} - -type LicenseStatus struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` - Locked bool `json:"locked"` - Message string `json:"message"` - Fingerprint string `json:"fingerprint"` - FingerprintShort string `json:"fingerprint_short"` - Hostname string `json:"hostname"` - TrialDays int `json:"trial_days"` - DaysRemaining int `json:"days_remaining"` - TrialStartedAt string `json:"trial_started_at,omitempty"` - TrialExpiresAt string `json:"trial_expires_at,omitempty"` - Customer string `json:"customer,omitempty"` - LicenseID string `json:"license_id,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` - Features []string `json:"features,omitempty"` - ActivationConfigured bool `json:"activation_configured"` - Tampered bool `json:"tampered"` -} - -type TrialState struct { - FirstRunUTC string `json:"first_run_utc"` - LastSeenUTC string `json:"last_seen_utc"` - Fingerprint string `json:"fingerprint"` - Checksum string `json:"checksum"` - Tampered bool `json:"tampered"` -} - -type SignedLicense struct { - App string `json:"app"` - LicenseID string `json:"license_id"` - Customer string `json:"customer"` - Fingerprint string `json:"fingerprint"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at,omitempty"` - Features []string `json:"features,omitempty"` - Signature string `json:"signature"` -} - -type licensePayload struct { - App string `json:"app"` - LicenseID string `json:"license_id"` - Customer string `json:"customer"` - Fingerprint string `json:"fingerprint"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at,omitempty"` - Features []string `json:"features,omitempty"` -} - -type LicenseManager struct { - mu sync.RWMutex - cfg LicenseConfig - dataDir string - trialPath string - licensePath string - publicKey ed25519.PublicKey - fingerprint string - components []string - hostname string - trial TrialState - active *SignedLicense - lastStatus LicenseStatus -} - -const trialSalt = "force-monitor-trial-v1" - -// --------------------------------------------------------------------------- -// Constructor -// --------------------------------------------------------------------------- - -func NewLicenseManager(cfg LicenseConfig, dataDir string) (*LicenseManager, error) { - if !cfg.Enabled { - return &LicenseManager{cfg: cfg, dataDir: dataDir}, nil - } - if cfg.TrialDays <= 0 { - cfg.TrialDays = 7 - } - if strings.TrimSpace(cfg.ProductCode) == "" { - cfg.ProductCode = "force_monitor" - } - if strings.TrimSpace(dataDir) == "" { - dataDir = "license" - } - if err := os.MkdirAll(dataDir, 0o755); err != nil { - return nil, fmt.Errorf("create license data dir: %w", err) - } - - fp, comps, err := buildMachineFingerprint(cfg.ProductCode) - if err != nil { - return nil, err - } - hostname, _ := os.Hostname() - - m := &LicenseManager{ - cfg: cfg, - dataDir: dataDir, - trialPath: filepath.Join(dataDir, "trial_state.json"), - licensePath: filepath.Join(dataDir, "license.json"), - fingerprint: fp, - components: comps, - hostname: hostname, - } - - if strings.TrimSpace(cfg.PublicKeyBase64) != "" { - pk, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.PublicKeyBase64)) - if err != nil { - return nil, fmt.Errorf("decode license public key: %w", err) - } - if len(pk) != ed25519.PublicKeySize { - return nil, fmt.Errorf("invalid license public key size") - } - m.publicKey = ed25519.PublicKey(pk) - } - - if err := m.loadOrCreateTrial(); err != nil { - return nil, err - } - if err := m.loadExistingLicense(); err != nil { - return nil, err - } - m.refreshStatusLocked() - return m, nil -} - -// --------------------------------------------------------------------------- -// Public methods -// --------------------------------------------------------------------------- - -func (m *LicenseManager) BuildActivationRequest() ActivationRequest { - m.mu.RLock() - defer m.mu.RUnlock() - return ActivationRequest{ - App: m.cfg.ProductCode, - Version: version, - GeneratedAt: time.Now().UTC().Format(time.RFC3339), - Hostname: m.hostname, - Platform: runtime.GOOS + "/" + runtime.GOARCH, - Fingerprint: m.fingerprint, - FingerprintShort: shortFingerprint(m.fingerprint), - Components: append([]string(nil), m.components...), - } -} - -func (m *LicenseManager) Status() LicenseStatus { - m.mu.Lock() - defer m.mu.Unlock() - m.refreshStatusLocked() - return m.lastStatus -} - -func (m *LicenseManager) Touch() error { - if m == nil || !m.cfg.Enabled { - return nil - } - m.mu.Lock() - defer m.mu.Unlock() - - now := time.Now().UTC() - lastSeen, _ := time.Parse(time.RFC3339, m.trial.LastSeenUTC) - if !lastSeen.IsZero() && now.Add(2*time.Minute).Before(lastSeen) { - m.trial.Tampered = true - } - if !lastSeen.IsZero() && now.Sub(lastSeen) < 15*time.Minute && !m.trial.Tampered { - m.refreshStatusLocked() - return nil - } - m.trial.LastSeenUTC = now.Format(time.RFC3339) - m.trial.Fingerprint = m.fingerprint - m.trial.Checksum = m.signTrialState(m.trial) - if err := writeJSONFileAtomic(m.trialPath, m.trial); err != nil { - return err - } - m.refreshStatusLocked() - return nil -} - -func (m *LicenseManager) ActivateFromText(text string) error { - m.mu.Lock() - defer m.mu.Unlock() - - if !m.cfg.Enabled { - return errors.New("licensing disabled") - } - if len(m.publicKey) != ed25519.PublicKeySize { - return errors.New("no license public key configured; set license.public_key_base64 first") - } - - text = strings.TrimSpace(text) - if text == "" { - return errors.New("license text is empty") - } - - var lic SignedLicense - if err := json.Unmarshal([]byte(text), &lic); err != nil { - return fmt.Errorf("parse license json: %w", err) - } - if err := m.validateLicenseLocked(lic); err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(m.licensePath), 0o755); err != nil { - return fmt.Errorf("create license dir: %w", err) - } - if err := writeBytesAtomic(m.licensePath, []byte(text)); err != nil { - return fmt.Errorf("write license: %w", err) - } - m.active = &lic - m.refreshStatusLocked() - return nil -} - -// --------------------------------------------------------------------------- -// Internal load / validate -// --------------------------------------------------------------------------- - -func (m *LicenseManager) loadOrCreateTrial() error { - state, err := readJSONFile[TrialState](m.trialPath) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("read trial state: %w", err) - } - now := time.Now().UTC().Format(time.RFC3339) - state = TrialState{ - FirstRunUTC: now, - LastSeenUTC: now, - Fingerprint: m.fingerprint, - } - state.Checksum = m.signTrialState(state) - if err := writeJSONFileAtomic(m.trialPath, state); err != nil { - return fmt.Errorf("create trial state: %w", err) - } - } - - if state.Checksum != m.signTrialState(state) { - state.Tampered = true - } - if state.Fingerprint != "" && state.Fingerprint != m.fingerprint { - state.Tampered = true - } - m.trial = state - return nil -} - -func (m *LicenseManager) loadExistingLicense() error { - data, err := os.ReadFile(m.licensePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - m.active = nil - return nil - } - return fmt.Errorf("read existing license: %w", err) - } - var lic SignedLicense - if err := json.Unmarshal(data, &lic); err != nil { - return fmt.Errorf("parse existing license: %w", err) - } - if err := m.validateLicenseLocked(lic); err != nil { - return nil - } - m.active = &lic - return nil -} - -func (m *LicenseManager) validateLicenseLocked(lic SignedLicense) error { - if strings.TrimSpace(lic.App) == "" { - return errors.New("license app is empty") - } - if lic.App != m.cfg.ProductCode { - return fmt.Errorf("license app mismatch: got %q want %q", lic.App, m.cfg.ProductCode) - } - if strings.TrimSpace(lic.Fingerprint) == "" { - return errors.New("license fingerprint is empty") - } - if !strings.EqualFold(strings.TrimSpace(lic.Fingerprint), strings.TrimSpace(m.fingerprint)) { - return errors.New("license fingerprint does not match this machine") - } - if strings.TrimSpace(lic.LicenseID) == "" { - return errors.New("license_id is required") - } - if strings.TrimSpace(lic.IssuedAt) == "" { - return errors.New("issued_at is required") - } - if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil { - return fmt.Errorf("invalid issued_at: %w", err) - } - if strings.TrimSpace(lic.ExpiresAt) != "" { - exp, err := time.Parse(time.RFC3339, lic.ExpiresAt) - if err != nil { - return fmt.Errorf("invalid expires_at: %w", err) - } - if time.Now().UTC().After(exp) { - return errors.New("license has expired") - } - } - if len(m.publicKey) != ed25519.PublicKeySize { - return errors.New("no public key configured") - } - sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(lic.Signature)) - if err != nil { - return fmt.Errorf("decode license signature: %w", err) - } - payload := licensePayload{ - App: lic.App, - LicenseID: lic.LicenseID, - Customer: lic.Customer, - Fingerprint: lic.Fingerprint, - IssuedAt: lic.IssuedAt, - ExpiresAt: lic.ExpiresAt, - Features: lic.Features, - } - b, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal license payload: %w", err) - } - if !ed25519.Verify(m.publicKey, b, sig) { - return errors.New("invalid license signature") - } - return nil -} - -func (m *LicenseManager) refreshStatusLocked() { - status := LicenseStatus{ - Enabled: m.cfg.Enabled, - Fingerprint: m.fingerprint, - FingerprintShort: shortFingerprint(m.fingerprint), - Hostname: m.hostname, - TrialDays: m.cfg.TrialDays, - ActivationConfigured: len(m.publicKey) == ed25519.PublicKeySize, - Tampered: m.trial.Tampered, - } - - if !m.cfg.Enabled { - status.Mode = "disabled" - status.Message = "licensing disabled" - status.Locked = false - m.lastStatus = status - return - } - - if m.active != nil { - status.Mode = "licensed" - status.Locked = false - status.Message = "license active" - status.Customer = m.active.Customer - status.LicenseID = m.active.LicenseID - status.Features = append([]string(nil), m.active.Features...) - status.ExpiresAt = m.active.ExpiresAt - m.lastStatus = status - return - } - - start, _ := time.Parse(time.RFC3339, m.trial.FirstRunUTC) - status.TrialStartedAt = m.trial.FirstRunUTC - - if start.IsZero() || m.trial.Tampered { - status.Mode = "trial_invalid" - status.Locked = true - status.Message = "trial state invalid or tampered" - m.lastStatus = status - return - } - - exp := start.Add(time.Duration(m.cfg.TrialDays) * 24 * time.Hour) - status.TrialExpiresAt = exp.Format(time.RFC3339) - days := int(time.Until(exp).Hours() / 24) - if time.Until(exp) > 0 { - days++ - } - if days < 0 { - days = 0 - } - status.DaysRemaining = days - - if time.Now().UTC().Before(exp) { - status.Mode = "trial" - status.Locked = false - status.Message = fmt.Sprintf("trial active: %d day(s) remaining", status.DaysRemaining) - m.lastStatus = status - return - } - - if m.cfg.RequireAfterTrial { - status.Mode = "expired" - status.Locked = true - status.Message = "trial expired; activation required" - } else { - status.Mode = "grace" - status.Locked = false - status.Message = "trial expired, but app allowed to continue" - } - m.lastStatus = status -} - -// --------------------------------------------------------------------------- -// Fingerprint helpers -// --------------------------------------------------------------------------- - -func buildMachineFingerprint(productCode string) (string, []string, error) { - parts := []string{} - - add := func(label, value string) { - v := normalizeMachineValue(value) - if v != "" { - parts = append(parts, label+"="+v) - } - } - - if runtime.GOOS == "windows" { - add("uuid", runWindowsCommand(`(Get-CimInstance Win32_ComputerSystemProduct).UUID`)) - add("board", runWindowsCommand(`(Get-CimInstance Win32_BaseBoard).SerialNumber`)) - add("machineguid", runWindowsCommand(`(Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Cryptography").MachineGuid`)) - add("bios", runWindowsCommand(`(Get-CimInstance Win32_BIOS).SerialNumber`)) - } else { - add("machineid", readTextFile("/etc/machine-id")) - add("product_uuid", readTextFile("/sys/class/dmi/id/product_uuid")) - add("board_serial", readTextFile("/sys/class/dmi/id/board_serial")) - } - - add("hostname", hostNameSafe()) - add("mac", firstPhysicalMAC()) - - if len(parts) == 0 { - return "", nil, errors.New("could not derive machine fingerprint") - } - - raw := strings.Join(parts, "|") + "|app=" + normalizeMachineValue(productCode) + "|salt=force-monitor-fp-v1" - sum := sha256.Sum256([]byte(raw)) - return strings.ToUpper(hex.EncodeToString(sum[:])), parts, nil -} - -func normalizeMachineValue(s string) string { - s = strings.TrimSpace(strings.ToUpper(s)) - s = strings.ReplaceAll(s, "\x00", "") - s = strings.ReplaceAll(s, "\r", "") - s = strings.ReplaceAll(s, "\n", "") - s = strings.ReplaceAll(s, " ", "") - switch s { - case "", "TOBEFILLEDBYO.E.M.", "NONE", "DEFAULTSTRING", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", "00000000-0000-0000-0000-000000000000": - return "" - } - return s -} - -func runWindowsCommand(script string) string { - cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) - out, err := cmd.Output() - if err == nil { - return string(out) - } - cmd = exec.Command("cmd.exe", "/C", "powershell", "-NoProfile", "-NonInteractive", "-Command", script) - out, err = cmd.Output() - if err == nil { - return string(out) - } - return "" -} - -func readTextFile(path string) string { - b, err := os.ReadFile(path) - if err != nil { - return "" - } - return string(b) -} - -func hostNameSafe() string { - h, _ := os.Hostname() - return h -} - -func firstPhysicalMAC() string { - ifs, err := net.Interfaces() - if err != nil { - return "" - } - for _, ifc := range ifs { - if ifc.Flags&net.FlagLoopback != 0 { - continue - } - if ifc.Flags&net.FlagUp == 0 { - continue - } - if len(ifc.HardwareAddr) == 0 { - continue - } - return ifc.HardwareAddr.String() - } - return "" -} - -func shortFingerprint(full string) string { - full = strings.TrimSpace(full) - if len(full) <= 16 { - return full - } - return full[:8] + "-" + full[8:16] -} - -// --------------------------------------------------------------------------- -// Trial integrity helpers -// --------------------------------------------------------------------------- - -func (m *LicenseManager) signTrialState(state TrialState) string { - sum := sha256.Sum256([]byte( - strings.Join([]string{ - state.FirstRunUTC, - state.LastSeenUTC, - state.Fingerprint, - boolToString(state.Tampered), - trialSalt, - m.cfg.ProductCode, - }, "|"), - )) - return hex.EncodeToString(sum[:]) -} - -func boolToString(v bool) string { - if v { - return "1" - } - return "0" -} - -// --------------------------------------------------------------------------- -// File helpers -// --------------------------------------------------------------------------- - -func writeJSONFileAtomic(path string, v any) error { - b, err := json.MarshalIndent(v, "", " ") - if err != nil { - return err - } - return writeBytesAtomic(path, b) -} - -func writeBytesAtomic(path string, b []byte) error { - tmp := path + ".tmp" - if err := os.WriteFile(tmp, b, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} - -func readJSONFile[T any](path string) (T, error) { - var zero T - b, err := os.ReadFile(path) - if err != nil { - return zero, err - } - var out T - if err := json.Unmarshal(b, &out); err != nil { - return zero, err - } - return out, nil -} - -// Optional helper for external tools: create canonical bytes to sign. -// Keep this available so you can reuse the same code in a separate -// private license generator app without changing the verification logic. -func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) { - payload := licensePayload{ - App: lic.App, - LicenseID: lic.LicenseID, - Customer: lic.Customer, - Fingerprint: lic.Fingerprint, - IssuedAt: lic.IssuedAt, - ExpiresAt: lic.ExpiresAt, - Features: lic.Features, - } - return json.Marshal(payload) -} - -// Helper for a future private signing tool. -func SignLicenseWithPrivateKey(lic SignedLicense, privateKeyBase64 string) (SignedLicense, error) { - privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64)) - if err != nil { - return lic, fmt.Errorf("decode private key: %w", err) - } - if len(privRaw) != ed25519.PrivateKeySize { - return lic, fmt.Errorf("invalid private key size") - } - payloadBytes, err := MarshalLicensePayloadForSigning(lic) - if err != nil { - return lic, err - } - sig := ed25519.Sign(ed25519.PrivateKey(privRaw), payloadBytes) - lic.Signature = base64.StdEncoding.EncodeToString(sig) - return lic, nil -} - -// Small utility for loading a signed license from a reader if you later want -// to support multipart file upload without changing the validation flow. -func ReadLicenseText(r io.Reader) (string, error) { - b, err := io.ReadAll(r) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil -} diff --git a/main.go b/main.go index 602fa9e..68c2e07 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,7 @@ import ( //go:embed static var embeddedStaticFiles embed.FS -const version = "1.0.3" +const version = "1.0.5" // --------------------------------------------------------------------------- // Config structs @@ -538,6 +538,87 @@ type HistoryAnalyticsResponse struct { WorstImbalances []HistoryPeakPoint `json:"worst_imbalances"` } +type HistogramBin struct { + Start float64 `json:"start"` + End float64 `json:"end"` + Count int `json:"count"` + Percent float64 `json:"percent"` +} + +type ProcessCapabilityResponse struct { + Window string `json:"window"` + From string `json:"from"` + To string `json:"to"` + SampleCount int `json:"sample_count"` + TotalMeanPct float64 `json:"total_mean_pct"` + TotalStdPct float64 `json:"total_std_pct"` + TotalP95Pct float64 `json:"total_p95_pct"` + TotalP99Pct float64 `json:"total_p99_pct"` + TotalCpuWarning float64 `json:"total_cpu_warning"` + TotalCpuCritical float64 `json:"total_cpu_critical"` + TotalCpkWarning float64 `json:"total_cpk_warning"` + TotalCpkCritical float64 `json:"total_cpk_critical"` + ImbalanceMeanPct float64 `json:"imbalance_mean_pct"` + ImbalanceStdPct float64 `json:"imbalance_std_pct"` + ImbalanceP95Pct float64 `json:"imbalance_p95_pct"` + ImbalanceCpuWarning float64 `json:"imbalance_cpu_warning"` + ImbalanceCpuCritical float64 `json:"imbalance_cpu_critical"` + ImbalanceCpkWarning float64 `json:"imbalance_cpk_warning"` + ImbalanceCpkCritical float64 `json:"imbalance_cpk_critical"` + TotalAboveWarningPct float64 `json:"total_above_warning_pct"` + TotalAboveCriticalPct float64 `json:"total_above_critical_pct"` + ImbalanceAboveWarningPct float64 `json:"imbalance_above_warning_pct"` + ImbalanceAboveCriticalPct float64 `json:"imbalance_above_critical_pct"` + LeftRightCorrelation float64 `json:"left_right_correlation"` + SuggestedAction string `json:"suggested_action"` + Stability string `json:"stability"` + StabilityReason string `json:"stability_reason"` + TotalHistogram []HistogramBin `json:"total_histogram"` + ImbalanceHistogram []HistogramBin `json:"imbalance_histogram"` + TopOutliers []HistoryPeakPoint `json:"top_outliers"` +} + +type ReportBucket struct { + Label string `json:"label"` + AvgTotalPct float64 `json:"avg_total_pct"` + MaxTotalPct float64 `json:"max_total_pct"` + AvgImbalancePct float64 `json:"avg_imbalance_pct"` + Samples int `json:"samples"` + WarningEvents int `json:"warning_events"` + CriticalEvents int `json:"critical_events"` + PLCDisconnects int `json:"plc_disconnects"` +} + +type ReportSummaryResponse struct { + Window string `json:"window"` + From string `json:"from"` + To string `json:"to"` + SampleCount int `json:"sample_count"` + AverageTotalPct float64 `json:"average_total_pct"` + AverageTotalKN float64 `json:"average_total_kn"` + PeakTotalPct float64 `json:"peak_total_pct"` + PeakTotalKN float64 `json:"peak_total_kn"` + AverageImbalancePct float64 `json:"average_imbalance_pct"` + PeakImbalancePct float64 `json:"peak_imbalance_pct"` + 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"` + WarningEvents int `json:"warning_events"` + CriticalEvents int `json:"critical_events"` + PLCDisconnects int `json:"plc_disconnects"` + HealthScore int `json:"health_score"` + AvailabilityPct float64 `json:"availability_pct"` + ForceDeltaPct float64 `json:"force_delta_pct"` + ImbalanceDeltaPct float64 `json:"imbalance_delta_pct"` + Stability string `json:"stability"` + StabilityReason string `json:"stability_reason"` + ExecutiveSummary string `json:"executive_summary"` + Findings []string `json:"findings"` + Buckets []ReportBucket `json:"buckets"` + TopPeaks []HistoryPeakPoint `json:"top_peaks"` +} + type TrendResponse struct { Window string `json:"window"` AvgPeak5m float32 `json:"avg_peak_5m"` @@ -2466,6 +2547,408 @@ func maxInt64(a, b int64) int64 { return b } +func capabilityIndex(mean, sigma, usl, lsl float64) float64 { + if sigma <= 0 { + return 0 + } + upper := (usl - mean) / (3 * sigma) + lower := (mean - lsl) / (3 * sigma) + return math.Min(upper, lower) +} + +func oneSidedCapability(mean, sigma, usl float64) float64 { + if sigma <= 0 { + return 0 + } + return (usl - mean) / (3 * sigma) +} + +func safePercent(count, total int) float64 { + if total <= 0 { + return 0 + } + return (float64(count) / float64(total)) * 100 +} + +func buildHistogram(values []float64, bins int, minVal, maxVal float64) []HistogramBin { + if bins <= 0 { + bins = 12 + } + if len(values) == 0 { + return []HistogramBin{} + } + if maxVal <= minVal { + maxVal = minVal + 1 + } + width := (maxVal - minVal) / float64(bins) + counts := make([]int, bins) + for _, v := range values { + idx := int((v - minVal) / width) + if idx < 0 { + idx = 0 + } + if idx >= bins { + idx = bins - 1 + } + counts[idx]++ + } + out := make([]HistogramBin, 0, bins) + total := len(values) + for i := 0; i < bins; i++ { + start := minVal + float64(i)*width + end := start + width + if i == bins-1 { + end = maxVal + } + out = append(out, HistogramBin{Start: start, End: end, Count: counts[i], Percent: safePercent(counts[i], total)}) + } + return out +} + +func correlationCoefficient(xs, ys []float64) float64 { + if len(xs) == 0 || len(xs) != len(ys) { + return 0 + } + var sumX, sumY, sumXX, sumYY, sumXY float64 + n := float64(len(xs)) + for i := range xs { + x, y := xs[i], ys[i] + sumX += x + sumY += y + sumXX += x * x + sumYY += y * y + sumXY += x * y + } + num := (n * sumXY) - (sumX * sumY) + denX := (n * sumXX) - (sumX * sumX) + denY := (n * sumYY) - (sumY * sumY) + if denX <= 0 || denY <= 0 { + return 0 + } + return num / math.Sqrt(denX*denY) +} + +func queryProcessCapability(ctx context.Context, window time.Duration, label string) (ProcessCapabilityResponse, error) { + now := time.Now().UTC() + startNs := now.UnixNano() - window.Nanoseconds() + 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 ProcessCapabilityResponse{}, err + } + defer rows.Close() + + var firstTS, lastTS int64 + var totalStats, imbalanceStats runningStats + totalValues := make([]float64, 0, 2048) + imbalanceValues := make([]float64, 0, 2048) + leftValues := make([]float64, 0, 2048) + rightValues := make([]float64, 0, 2048) + topOutliers := make([]HistoryPeakPoint, 0, 8) + warningCount, criticalCount := 0, 0 + imbWarnCount, imbCritCount := 0, 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 ProcessCapabilityResponse{}, err + } + if firstTS == 0 { + firstTS = tsUnix + } + lastTS = tsUnix + totalStats.Add(totalPct) + imbalanceStats.Add(imbalancePct) + totalValues = append(totalValues, totalPct) + imbalanceValues = append(imbalanceValues, imbalancePct) + leftValues = append(leftValues, leftPct) + rightValues = append(rightValues, rightPct) + if totalPct >= cfgSnap.Thresholds.WarningPercent { + warningCount++ + } + if totalPct >= cfgSnap.Thresholds.CriticalPercent { + criticalCount++ + } + if imbalancePct >= cfgSnap.Thresholds.ImbalanceWarningPercent { + imbWarnCount++ + } + if imbalancePct >= cfgSnap.Thresholds.ImbalanceCriticalPercent { + imbCritCount++ + } + 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, + } + score := math.Abs(totalPct-cfgSnap.Thresholds.CriticalPercent) + (imbalancePct * 1.5) + topOutliers = insertPeakDescending(topOutliers, peak, 8, func(p HistoryPeakPoint) float64 { + return math.Abs(p.TotalPercent-cfgSnap.Thresholds.CriticalPercent) + (p.ImbalancePercent * 1.5) + }) + _ = score + } + if err := rows.Err(); err != nil { + return ProcessCapabilityResponse{}, err + } + sort.Float64s(totalValues) + sort.Float64s(imbalanceValues) + + trendResp, err := buildTrendResponse(ctx, window, label) + if err != nil { + return ProcessCapabilityResponse{}, err + } + + resp := ProcessCapabilityResponse{ + 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, + TotalMeanPct: totalStats.Avg(), + TotalStdPct: totalStats.StdDev(), + TotalP95Pct: percentileFromSorted(totalValues, 0.95), + TotalP99Pct: percentileFromSorted(totalValues, 0.99), + ImbalanceMeanPct: imbalanceStats.Avg(), + ImbalanceStdPct: imbalanceStats.StdDev(), + ImbalanceP95Pct: percentileFromSorted(imbalanceValues, 0.95), + TotalAboveWarningPct: safePercent(warningCount, totalStats.count), + TotalAboveCriticalPct: safePercent(criticalCount, totalStats.count), + ImbalanceAboveWarningPct: safePercent(imbWarnCount, imbalanceStats.count), + ImbalanceAboveCriticalPct: safePercent(imbCritCount, imbalanceStats.count), + LeftRightCorrelation: correlationCoefficient(leftValues, rightValues), + Stability: trendResp.ProcessStability, + StabilityReason: trendResp.StabilityReason, + TotalHistogram: buildHistogram(totalValues, 14, 0, math.Max(cfgSnap.Thresholds.GaugeMaxPercent, totalStats.max)), + ImbalanceHistogram: buildHistogram(imbalanceValues, 12, 0, math.Max(cfgSnap.Thresholds.ImbalanceCriticalPercent*1.5, imbalanceStats.max)), + TopOutliers: topOutliers, + } + resp.TotalCpuWarning = oneSidedCapability(resp.TotalMeanPct, resp.TotalStdPct, cfgSnap.Thresholds.WarningPercent) + resp.TotalCpuCritical = oneSidedCapability(resp.TotalMeanPct, resp.TotalStdPct, cfgSnap.Thresholds.CriticalPercent) + resp.TotalCpkWarning = capabilityIndex(resp.TotalMeanPct, resp.TotalStdPct, cfgSnap.Thresholds.WarningPercent, 0) + resp.TotalCpkCritical = capabilityIndex(resp.TotalMeanPct, resp.TotalStdPct, cfgSnap.Thresholds.CriticalPercent, 0) + resp.ImbalanceCpuWarning = oneSidedCapability(resp.ImbalanceMeanPct, resp.ImbalanceStdPct, cfgSnap.Thresholds.ImbalanceWarningPercent) + resp.ImbalanceCpuCritical = oneSidedCapability(resp.ImbalanceMeanPct, resp.ImbalanceStdPct, cfgSnap.Thresholds.ImbalanceCriticalPercent) + resp.ImbalanceCpkWarning = capabilityIndex(resp.ImbalanceMeanPct, resp.ImbalanceStdPct, cfgSnap.Thresholds.ImbalanceWarningPercent, 0) + resp.ImbalanceCpkCritical = capabilityIndex(resp.ImbalanceMeanPct, resp.ImbalanceStdPct, cfgSnap.Thresholds.ImbalanceCriticalPercent, 0) + + if resp.SampleCount == 0 { + resp.From = time.Unix(0, startNs).Local().Format(time.RFC3339) + resp.To = now.Local().Format(time.RFC3339) + resp.SuggestedAction = "No process data in selected window. Check PLC connection, machine runtime, or choose a wider period." + } else { + switch { + case resp.TotalCpkCritical < 1.0 || resp.ImbalanceCpkCritical < 1.0: + resp.SuggestedAction = "Capability is weak versus critical limits. Review overload moments, alignment, tooling, and setup repeatability." + case resp.TotalAboveWarningPct > 10 || resp.ImbalanceAboveWarningPct > 10: + resp.SuggestedAction = "Capability is marginal. Investigate drift sources and reduce high-variation periods before they become critical." + default: + resp.SuggestedAction = "Capability looks healthy for the selected window. Use this as a reference baseline for future comparisons." + } + } + return resp, nil +} + +func reportBucketLabel(t time.Time, window time.Duration) string { + t = t.Local() + switch { + case window <= 2*time.Hour: + return t.Format("15:04") + case window <= 48*time.Hour: + return t.Format("02.01 15:00") + default: + return t.Format("02.01") + } +} + +func queryReportSummary(ctx context.Context, window time.Duration, label string) (ReportSummaryResponse, error) { + analytics, err := queryHistoryAnalytics(ctx, window, label) + if err != nil { + return ReportSummaryResponse{}, err + } + trendResp, err := buildTrendResponse(ctx, window, label) + if err != nil { + return ReportSummaryResponse{}, err + } + cfgSnap := getConfigSnapshot() + now := time.Now().UTC() + startNs := now.UnixNano() - window.Nanoseconds() + + rows, err := db.QueryContext(ctx, ` + SELECT ts_unix_ns, sum_pct, imbalance_pct + FROM samples + WHERE ts_unix_ns >= ? + ORDER BY ts_unix_ns ASC + `, startNs) + if err != nil { + return ReportSummaryResponse{}, err + } + defer rows.Close() + + type bucketAgg struct { + sumTotal, maxTotal, sumImb float64 + samples int + } + bucketMap := map[string]*bucketAgg{} + order := []string{} + for rows.Next() { + var tsUnix int64 + var totalPct, imbPct float64 + if err := rows.Scan(&tsUnix, &totalPct, &imbPct); err != nil { + return ReportSummaryResponse{}, err + } + labelKey := reportBucketLabel(time.Unix(0, tsUnix), window) + bucket := bucketMap[labelKey] + if bucket == nil { + bucket = &bucketAgg{} + bucketMap[labelKey] = bucket + order = append(order, labelKey) + } + bucket.sumTotal += totalPct + bucket.sumImb += imbPct + if totalPct > bucket.maxTotal { + bucket.maxTotal = totalPct + } + bucket.samples++ + } + if err := rows.Err(); err != nil { + return ReportSummaryResponse{}, err + } + + warnEventsByBucket := map[string]int{} + criticalEventsByBucket := map[string]int{} + plcDiscByBucket := map[string]int{} + alarmRows, err := db.QueryContext(ctx, ` + SELECT ts_unix_ns, severity, source, code + FROM alarm_events + WHERE ts_unix_ns >= ? + ORDER BY ts_unix_ns ASC + `, startNs) + if err != nil { + return ReportSummaryResponse{}, err + } + for alarmRows.Next() { + var tsUnix int64 + var severity, source, code string + if err := alarmRows.Scan(&tsUnix, &severity, &source, &code); err != nil { + alarmRows.Close() + return ReportSummaryResponse{}, err + } + labelKey := reportBucketLabel(time.Unix(0, tsUnix), window) + switch severity { + case "warning": + warnEventsByBucket[labelKey]++ + case "critical": + criticalEventsByBucket[labelKey]++ + } + if source == "plc" && code == "plc_disconnected" { + plcDiscByBucket[labelKey]++ + } + } + alarmRows.Close() + + buckets := make([]ReportBucket, 0, len(order)) + for _, key := range order { + b := bucketMap[key] + avgTotal := 0.0 + avgImb := 0.0 + if b.samples > 0 { + avgTotal = b.sumTotal / float64(b.samples) + avgImb = b.sumImb / float64(b.samples) + } + buckets = append(buckets, ReportBucket{ + Label: key, AvgTotalPct: avgTotal, MaxTotalPct: b.maxTotal, AvgImbalancePct: avgImb, Samples: b.samples, + WarningEvents: warnEventsByBucket[key], CriticalEvents: criticalEventsByBucket[key], PLCDisconnects: plcDiscByBucket[key], + }) + } + + health := 100.0 + health -= analytics.WarningRatePct * 0.55 + health -= analytics.CriticalRatePct * 1.15 + health -= analytics.ImbalanceWarningRatePct * 0.45 + health -= analytics.ImbalanceCriticalRatePct * 1.00 + health -= float64(analytics.CriticalEvents) * 1.5 + health -= float64(analytics.PLCDisconnects) * 8 + if trendResp.ProcessStability == "unstable" { + health -= 10 + } + if trendResp.ProcessStability == "caution" { + health -= 4 + } + if health < 0 { + health = 0 + } + if health > 100 { + health = 100 + } + + availability := 100.0 + if len(buckets) > 0 { + availability -= math.Min(25, float64(analytics.PLCDisconnects)*2.5) + } + if availability < 0 { + availability = 0 + } + + findings := []string{} + if analytics.CriticalRatePct > 0 { + findings = append(findings, fmt.Sprintf("Critical-force occupancy is %.1f%% of samples.", analytics.CriticalRatePct)) + } + if analytics.ImbalanceCriticalRatePct > 0 { + findings = append(findings, fmt.Sprintf("Critical imbalance appears in %.1f%% of samples.", analytics.ImbalanceCriticalRatePct)) + } + if math.Abs(analytics.PreviousWindowDeltaPct) >= 3 { + trendWord := "up" + if analytics.PreviousWindowDeltaPct < 0 { + trendWord = "down" + } + findings = append(findings, fmt.Sprintf("Average total force is %s %.1f%% versus the previous window.", trendWord, math.Abs(analytics.PreviousWindowDeltaPct))) + } + if analytics.PLCDisconnects > 0 { + findings = append(findings, fmt.Sprintf("PLC disconnected %d time(s) in the selected report window.", analytics.PLCDisconnects)) + } + if len(findings) == 0 { + findings = append(findings, "No major process exceptions detected in the selected report window.") + } + + execSummary := fmt.Sprintf("Health score %d/100. Avg total peak %.1f%s, peak %.1f%s, avg imbalance %.1f%s, with %d warning and %d critical events.", + int(math.Round(health)), analytics.TotalAvgPct, cfgSnap.UI.UnitPct, analytics.TotalMaxPct, cfgSnap.UI.UnitPct, analytics.ImbalanceAvgPct, cfgSnap.UI.UnitPct, analytics.WarningEvents, analytics.CriticalEvents) + + resp := ReportSummaryResponse{ + Window: label, + From: analytics.From, + To: analytics.To, + SampleCount: analytics.SampleCount, + AverageTotalPct: analytics.TotalAvgPct, + AverageTotalKN: analytics.TotalAvgKN, + PeakTotalPct: analytics.TotalMaxPct, + PeakTotalKN: analytics.TotalMaxKN, + AverageImbalancePct: analytics.ImbalanceAvgPct, + PeakImbalancePct: analytics.ImbalanceMaxPct, + WarningRatePct: analytics.WarningRatePct, + CriticalRatePct: analytics.CriticalRatePct, + ImbalanceWarningRatePct: analytics.ImbalanceWarningRatePct, + ImbalanceCriticalRatePct: analytics.ImbalanceCriticalRatePct, + WarningEvents: analytics.WarningEvents, + CriticalEvents: analytics.CriticalEvents, + PLCDisconnects: analytics.PLCDisconnects, + HealthScore: int(math.Round(health)), + AvailabilityPct: availability, + ForceDeltaPct: analytics.PreviousWindowDeltaPct, + ImbalanceDeltaPct: analytics.PreviousImbalanceDeltaPct, + Stability: trendResp.ProcessStability, + StabilityReason: trendResp.StabilityReason, + ExecutiveSummary: execSummary, + Findings: findings, + Buckets: buckets, + TopPeaks: analytics.TopPeaks, + } + return resp, nil +} + // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- @@ -2706,6 +3189,81 @@ func serveLicensePage(w http.ResponseWriter, r *http.Request) { serveEmbeddedHTMLPage(w, "static/license.html") } +func apiProcessCapability(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 := queryProcessCapability(r.Context(), window, label) + if err != nil { + log.Printf("process capability query failed: %v", err) + http.Error(w, `{"error":"process capability query failed"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func apiReportsSummary(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 := queryReportSummary(r.Context(), window, label) + if err != nil { + log.Printf("reports summary query failed: %v", err) + http.Error(w, `{"error":"reports summary query failed"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func serveKioskPage(w http.ResponseWriter, r *http.Request) { + if redirectToCanonicalPath(w, r, "/kiosk") { + return + } + if r.URL.Path != "/kiosk" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/kiosk.html") +} + +func serveProcessCapabilityPage(w http.ResponseWriter, r *http.Request) { + if redirectToCanonicalPath(w, r, "/process-capability") { + return + } + if r.URL.Path != "/process-capability" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/process-capability.html") +} + +func serveReportsPage(w http.ResponseWriter, r *http.Request) { + if redirectToCanonicalPath(w, r, "/reports") { + return + } + if r.URL.Path != "/reports" { + http.NotFound(w, r) + return + } + serveEmbeddedHTMLPage(w, "static/reports.html") +} + func serveUI(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { // Check license before serving the UI @@ -2714,6 +3272,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) { if status.Locked { // Serve fallback activation page when locked w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") fmt.Fprintf(w, ` Force Monitor — License Required @@ -2789,6 +3348,7 @@ async function activate(){ } w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") if err := tmpl.Execute(w, data); err != nil { log.Printf("template execute error: %v", err) } @@ -3149,6 +3709,12 @@ func main() { mux.HandleFunc("/alarms/", serveAlarmsPage) mux.HandleFunc("/history", serveHistoryPage) mux.HandleFunc("/history/", serveHistoryPage) + mux.HandleFunc("/kiosk", serveKioskPage) + mux.HandleFunc("/kiosk/", serveKioskPage) + mux.HandleFunc("/process-capability", serveProcessCapabilityPage) + mux.HandleFunc("/process-capability/", serveProcessCapabilityPage) + mux.HandleFunc("/reports", serveReportsPage) + mux.HandleFunc("/reports/", serveReportsPage) mux.HandleFunc("/license", serveLicensePage) mux.HandleFunc("/license/", serveLicensePage) mux.HandleFunc("/licence", serveLicensePage) @@ -3160,6 +3726,8 @@ func main() { mux.HandleFunc("/api/config/public", apiPublicConfig) mux.HandleFunc("/api/history", apiHistory) mux.HandleFunc("/api/history/analytics", apiHistoryAnalytics) + mux.HandleFunc("/api/process-capability", apiProcessCapability) + mux.HandleFunc("/api/reports/summary", apiReportsSummary) mux.HandleFunc("/api/trend", apiTrend) mux.HandleFunc("/api/alarms", apiAlarms) diff --git a/static/alarms.html b/static/alarms.html index 94b62c7..e6adb1f 100644 --- a/static/alarms.html +++ b/static/alarms.html @@ -91,9 +91,13 @@ Dashboard History Alarms + Kiosk + Process capability + Reports License
+
@@ -176,6 +180,7 @@
+ + + + + diff --git a/static/license.html b/static/license.html index 7008af4..4d88966 100644 --- a/static/license.html +++ b/static/license.html @@ -81,10 +81,13 @@ Dashboard History Alarms + Kiosk + Process capability + Reports License - Licence alias
+ @@ -157,6 +160,7 @@ + + +
+ +
Engineering capability

Process Capability & Distribution

Histogram-based force and imbalance capability, one-sided CPU/CPK-style indicators against your configured thresholds, correlation between left and right columns, and suggested engineering action.
Window: --
+
+
Total Cpk @ critical
--
Capability versus critical load limit
Imbalance Cpk @ critical
--
Capability versus critical imbalance limit
Left ↔ right correlation
--
Closer to 1.00 means both sides move together
Suggested action
--
Loading capability guidance…
+

Total force distribution

histogram
Distribution of total peak force against configured warning and critical boundaries.

Imbalance distribution

histogram
Distribution of imbalance magnitude. A tight distribution below warning is usually what engineering wants.
+
Mean / σ total
--
P95 / P99 and warning occupancy
Mean / σ imbalance
--
P95 and critical occupancy
CPU warning / critical
--
One-sided capability against upper limits
Stability
--
Loading…
+

Top outliers

review points
Combined overload and imbalance stress points worth engineering review.
TimeTotal %Total kNL %R %Imb %
No data
+
+ + diff --git a/static/reports.html b/static/reports.html new file mode 100644 index 0000000..1de000c --- /dev/null +++ b/static/reports.html @@ -0,0 +1,25 @@ + +Force Monitor — Reports + + +
+ +
Management & engineering report

Shift, Day & Week Reports

A report-friendly view for engineering and boss departments with health score, availability estimate, event counts, peak summaries, trend deltas, and a bucket chart for the selected period.
Window: --
+
+
Health score
--
Availability and event pressure
Avg / peak total
--
Total force summary
Avg / peak imbalance
--
Centering summary
Events
--
Warnings, criticals, PLC disconnects
+

Executive summary

loading
Loading report…
  • Loading findings…
+

Bucket trend

selected period
Each bucket summarizes average total force, maximum force, and event density inside the selected report window.
+

Top peaks in report window

top load moments
TimeTotal %Total kNImb %L %R %
No data
+
+ +