added for new version
This commit is contained in:
parent
bf435f9abf
commit
6e2cf09ce5
644
licence.go
644
licence.go
|
|
@ -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
|
||||
}
|
||||
570
main.go
570
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, `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Force Monitor — License Required</title>
|
||||
<style>body{font-family:Segoe UI,Arial,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:32px} .card{max-width:760px;margin:0 auto;background:#111827;padding:24px;border-radius:16px} code,pre{background:#020617;padding:10px;border-radius:12px;display:block;white-space:pre-wrap} button{padding:10px 16px;border-radius:10px;border:0;background:#2563eb;color:#fff;cursor:pointer} textarea{width:100%%;min-height:180px;border-radius:12px;padding:12px;background:#020617;color:#e2e8f0;border:1px solid #334155}</style></head>
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,9 +91,13 @@
|
|||
<a class="btn" href="/">Dashboard</a>
|
||||
<a class="btn" href="/history">History</a>
|
||||
<a class="btn primary" href="/alarms">Alarms</a>
|
||||
<a class="btn" href="/kiosk">Kiosk</a>
|
||||
<a class="btn" href="/process-capability">Process capability</a>
|
||||
<a class="btn" href="/reports">Reports</a>
|
||||
<a class="btn" href="/license">License</a>
|
||||
<div class="spacer"></div>
|
||||
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
||||
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
||||
</div>
|
||||
|
||||
<div class="glass card" style="margin-bottom:18px;">
|
||||
|
|
@ -176,6 +180,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let allEvents = [];
|
||||
let refreshTimer = null;
|
||||
|
|
@ -356,8 +361,8 @@
|
|||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
document.getElementById('theme-toggle').addEventListener('click', () => setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light'));
|
||||
AppUI.initTheme();
|
||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||
document.getElementById('refresh-btn').addEventListener('click', fetchAlarms);
|
||||
document.getElementById('export-btn').addEventListener('click', exportCSV);
|
||||
document.getElementById('auto-refresh').addEventListener('change', syncAutoRefresh);
|
||||
|
|
|
|||
92
static/app-common.js
Normal file
92
static/app-common.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
|
||||
(function(){
|
||||
const THEME_KEY = 'force-monitor-theme';
|
||||
function byId(id){ return id ? document.getElementById(id) : null; }
|
||||
function setTheme(theme, opts){
|
||||
opts = opts || {};
|
||||
const t = theme === 'light' ? 'light' : 'dark';
|
||||
document.body.setAttribute('data-theme', t);
|
||||
try { localStorage.setItem(THEME_KEY, t); } catch (_) {}
|
||||
const btn = byId(opts.buttonId || 'theme-toggle');
|
||||
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
||||
if (typeof opts.onChange === 'function') opts.onChange(t);
|
||||
return t;
|
||||
}
|
||||
function initTheme(opts){
|
||||
opts = opts || {};
|
||||
let theme = 'dark';
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored === 'light' || stored === 'dark') theme = stored;
|
||||
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) theme = 'light';
|
||||
} catch (_) {}
|
||||
setTheme(theme, opts);
|
||||
const btn = byId(opts.buttonId || 'theme-toggle');
|
||||
if (btn && !btn.dataset.themeBound) {
|
||||
btn.dataset.themeBound = '1';
|
||||
btn.addEventListener('click', function(){
|
||||
setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light', opts);
|
||||
});
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
function updateFullscreenButton(buttonId){
|
||||
const btn = byId(buttonId || 'fullscreen-toggle');
|
||||
if (btn) btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||
}
|
||||
async function toggleFullscreen(buttonId){
|
||||
try {
|
||||
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
|
||||
else await document.exitFullscreen();
|
||||
} catch (err) {
|
||||
console.warn('Fullscreen error:', err);
|
||||
} finally {
|
||||
updateFullscreenButton(buttonId || 'fullscreen-toggle');
|
||||
}
|
||||
}
|
||||
function initFullscreen(opts){
|
||||
opts = opts || {};
|
||||
const buttonId = opts.buttonId || 'fullscreen-toggle';
|
||||
const btn = byId(buttonId);
|
||||
if (btn && !btn.dataset.fsBound) {
|
||||
btn.dataset.fsBound = '1';
|
||||
btn.addEventListener('click', function(){ toggleFullscreen(buttonId); });
|
||||
}
|
||||
if (!document.documentElement.dataset.fsListenerBound) {
|
||||
document.documentElement.dataset.fsListenerBound = '1';
|
||||
document.addEventListener('fullscreenchange', function(){
|
||||
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
|
||||
el.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||
});
|
||||
});
|
||||
}
|
||||
updateFullscreenButton(buttonId);
|
||||
}
|
||||
async function fetchJson(url, opts){
|
||||
opts = opts || {};
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = opts.timeoutMs || 8000;
|
||||
const timer = setTimeout(function(){ controller.abort(); }, timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: opts.method || 'GET',
|
||||
headers: opts.headers || undefined,
|
||||
body: opts.body,
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) { data = null; }
|
||||
if (!res.ok) {
|
||||
const err = new Error(data && data.error ? data.error : ('HTTP ' + res.status));
|
||||
err.response = res;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
window.AppUI = { setTheme, initTheme, updateFullscreenButton, toggleFullscreen, initFullscreen, fetchJson };
|
||||
})();
|
||||
|
|
@ -100,9 +100,13 @@
|
|||
<a class="btn" href="/">Dashboard</a>
|
||||
<a class="btn primary" href="/history">History</a>
|
||||
<a class="btn" href="/alarms">Alarms</a>
|
||||
<a class="btn" href="/kiosk">Kiosk</a>
|
||||
<a class="btn" href="/process-capability">Process capability</a>
|
||||
<a class="btn" href="/reports">Reports</a>
|
||||
<a class="btn" href="/license">License</a>
|
||||
<div class="spacer"></div>
|
||||
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
||||
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
||||
</div>
|
||||
|
||||
<div class="glass page-card">
|
||||
|
|
@ -213,6 +217,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let config = { ui:{ title:'Force Monitor', unit_force:'kN' }, thresholds:{ warning_percent:80, critical_percent:95, gauge_max_percent:130, imbalance_warning_percent:10, imbalance_critical_percent:20 } };
|
||||
let currentWindow = '15m';
|
||||
|
|
@ -507,7 +512,9 @@
|
|||
}
|
||||
|
||||
function wireEvents() {
|
||||
qs('theme-toggle').addEventListener('click', () => applyTheme(isLightTheme() ? 'dark' : 'light'));
|
||||
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
|
||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||
updateFullscreenButton();
|
||||
qs('refresh-btn').addEventListener('click', refreshAll);
|
||||
qs('export-csv').addEventListener('click', exportCsv);
|
||||
qs('apply-window').addEventListener('click', () => {
|
||||
|
|
@ -526,7 +533,7 @@
|
|||
}
|
||||
|
||||
(async function init() {
|
||||
initTheme();
|
||||
// theme initialized by AppUI
|
||||
wireEvents();
|
||||
buildChart();
|
||||
try { await loadConfig(); } catch (err) { setWarning('Failed to load public config: ' + err.message); }
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.dir-up { color: #facc15; }
|
||||
.dir-down { color: #34d399; }
|
||||
.dir-flat { color: #a1a1aa; }
|
||||
|
|
@ -280,12 +280,20 @@
|
|||
</style>
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<a href="/" class="control-btn">Dashboard</a>
|
||||
<a href="/history" class="control-btn">History</a>
|
||||
<a href="/alarms" class="control-btn">Alarms</a>
|
||||
<a href="/license" class="control-btn">License</a>
|
||||
<div class="w-[95vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
|
||||
<div class="glass border border-white/10 rounded-3xl p-4 mb-8">
|
||||
<div class="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/" class="control-btn" target="_self">Dashboard</a>
|
||||
<a href="/history" class="control-btn" target="_self">History</a>
|
||||
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
|
||||
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
|
||||
<a href="/process-capability" class="control-btn" target="_self">Process Capability</a>
|
||||
<a href="/reports" class="control-btn" target="_self">Reports</a>
|
||||
<a href="/license" class="control-btn" target="_self">License</a>
|
||||
</div>
|
||||
<div class="text-zinc-500 text-sm font-mono">Embedded UI navigation</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -322,6 +330,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="process-content">
|
||||
{{if .ShowVerdict}}
|
||||
<div id="verdict-card" class="verdict-card neutral mb-8">
|
||||
|
|
@ -578,7 +589,8 @@
|
|||
</div><!-- #process-content -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
const WARNING_PERCENT = {{.WarningPercent}};
|
||||
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
||||
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
||||
|
|
|
|||
103
static/kiosk.html
Normal file
103
static/kiosk.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Force Monitor — Kiosk</title>
|
||||
<style>
|
||||
:root{--bg1:#030712;--bg2:#0f172a;--panel:rgba(255,255,255,.06);--border:rgba(255,255,255,.1);--text:#f8fafc;--muted:#94a3b8;--ok:#34d399;--warn:#facc15;--bad:#f87171;}
|
||||
*{box-sizing:border-box} body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 20% 10%, rgba(56,189,248,.14), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
|
||||
.wrap{width:min(96vw,1800px);margin:0 auto;padding:18px 22px 28px;} .row,.nav{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:10px 14px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.05);color:var(--text);text-decoration:none;font-weight:600;cursor:pointer}
|
||||
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
|
||||
.hero{padding:18px 24px;margin-bottom:18px}.status{font-size:64px;font-weight:900;line-height:1;margin-top:12px}.sub{color:var(--muted)} .mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.card{padding:22px 24px}.label{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)} .value{font-size:54px;font-weight:900;line-height:1;margin-top:12px}
|
||||
.small{font-size:18px;color:var(--muted);margin-top:10px}.banner{padding:16px 18px;border-radius:18px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.14);display:none;margin-bottom:16px}.banner.show{display:block}
|
||||
.ok{color:var(--ok)} .warning{color:var(--warn)} .critical{color:var(--bad)} .neutral{color:var(--muted)}
|
||||
.split{display:grid;grid-template-columns:1.35fr .85fr;gap:16px}.panel{padding:18px 22px} ul{margin:12px 0 0;padding-left:18px} li{margin:8px 0;color:var(--muted)}
|
||||
@media (max-width:1200px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}.value{font-size:42px}.status{font-size:48px}}
|
||||
@media (max-width:760px){.cards{grid-template-columns:1fr}.wrap{padding:14px}.value{font-size:36px}.status{font-size:38px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="nav" style="margin-bottom:14px">
|
||||
<a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn" href="/process-capability">Process capability</a><a class="btn" href="/reports">Reports</a><a class="btn" href="/license">License</a>
|
||||
<div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-btn" class="btn" type="button">Enter fullscreen</button>
|
||||
</div>
|
||||
<div id="alarm-banner" class="banner"></div>
|
||||
<div class="glass hero">
|
||||
<div class="row"><div><div class="label" id="title-kicker">Force Monitor</div><div class="status" id="status-text">LOADING</div><div class="sub" id="status-reason">Preparing kiosk view…</div></div><div class="spacer"></div><div class="mono sub" id="clock">--</div></div>
|
||||
</div>
|
||||
<div class="grid cards">
|
||||
<div class="glass card"><div class="label">Total peak</div><div id="total-value" class="value mono">--</div><div id="total-sub" class="small">kN / %</div></div>
|
||||
<div class="glass card"><div class="label">Left</div><div id="left-value" class="value mono">--</div><div id="left-sub" class="small">kN / %</div></div>
|
||||
<div class="glass card"><div class="label">Right</div><div id="right-value" class="value mono">--</div><div id="right-sub" class="small">kN / %</div></div>
|
||||
<div class="glass card"><div class="label">Imbalance</div><div id="imb-value" class="value mono">--</div><div id="imb-sub" class="small">bias / trend</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="glass panel">
|
||||
<div class="row"><h2 style="margin:0;font-size:30px">Live production verdict</h2><div class="spacer"></div><span id="stale-pill" class="mono sub">Data freshness: --</span></div>
|
||||
<div id="verdict-summary" class="status neutral" style="font-size:56px;margin-top:14px">WAITING</div>
|
||||
<div id="verdict-detail" class="sub" style="font-size:22px;margin-top:12px">No PLC data yet.</div>
|
||||
<div class="row" style="margin-top:22px">
|
||||
<div class="glass" style="padding:14px 16px;min-width:220px"><div class="label">Trend direction</div><div id="trend-force" class="value mono" style="font-size:32px">--</div><div id="trend-force-sub" class="small">force drift</div></div>
|
||||
<div class="glass" style="padding:14px 16px;min-width:220px"><div class="label">Process stability</div><div id="trend-stability" class="value mono" style="font-size:32px">--</div><div id="trend-stability-sub" class="small">stability</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass panel">
|
||||
<div class="row"><h2 style="margin:0;font-size:28px">Active attention items</h2><div class="spacer"></div><span class="sub mono" id="last-refresh">Last refresh: --</span></div>
|
||||
<ul id="attention-list"><li>Loading live status…</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let cfg={ui:{title:'Force Monitor',unit_force:'kN',unit_percent:'%'},thresholds:{warning_percent:80,critical_percent:95,imbalance_warning_percent:10,imbalance_critical_percent:20}};
|
||||
function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} const btn=document.getElementById('theme-toggle'); if(btn) btn.textContent=theme==='light'?'Dark theme':'Light theme';}
|
||||
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark');}
|
||||
function updateFullscreenButton(){const btn=document.getElementById('fullscreen-btn'); if(btn) btn.textContent=document.fullscreenElement?'Exit fullscreen':'Enter fullscreen';}
|
||||
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok';
|
||||
function zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
|
||||
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
|
||||
async function loadCfg(){try{const r=await fetch('/api/config/public',{cache:'no-store'}); if(r.ok){cfg=await r.json(); setThemeTitle();}}catch(e){}}
|
||||
async function refreshAll(){
|
||||
try{
|
||||
const [dataRes, trendRes, alarmsRes]=await Promise.all([
|
||||
fetch('/api/data',{cache:'no-store'}), fetch('/api/trend?window=15m',{cache:'no-store'}), fetch('/api/alarms?limit=8',{cache:'no-store'})
|
||||
]);
|
||||
if(dataRes.status===403){document.getElementById('status-text').textContent='LICENSE REQUIRED';document.getElementById('status-text').className='status critical';document.getElementById('status-reason').textContent='Open /license to activate the application.';return;}
|
||||
const d=await dataRes.json(); const t=trendRes.ok?await trendRes.json():{}; const a=alarmsRes.ok?await alarmsRes.json():{events:[]};
|
||||
const connected=!!d.connected, stale=!!d.stale; const lp=Number(d.sila_l)||0, rp=Number(d.sila_r)||0, tp=Number(d.sum_percent)||0, tkn=Number(d.sum_kn)||0, imb=Number(d.imbalance_percent)||0, bias=Number(d.bias_percent)||0;
|
||||
const lkn=Number(d.sila_l_kn)||0, rkn=Number(d.sila_r_kn)||0;
|
||||
document.getElementById('clock').textContent=new Date().toLocaleString(); document.getElementById('last-refresh').textContent='Last refresh: '+new Date().toLocaleTimeString();
|
||||
document.getElementById('total-value').textContent=fmt(tkn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('total-sub').textContent=fmt(tp,1)+(cfg.ui.unit_percent||'%')+' total load';
|
||||
document.getElementById('left-value').textContent=fmt(lp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('left-sub').textContent=fmt(lkn,1)+' '+(cfg.ui.unit_force||'kN');
|
||||
document.getElementById('right-value').textContent=fmt(rp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('right-sub').textContent=fmt(rkn,1)+' '+(cfg.ui.unit_force||'kN');
|
||||
document.getElementById('imb-value').textContent=fmt(imb,1)+(cfg.ui.unit_percent||'%'); document.getElementById('imb-sub').textContent='Bias '+fmt(bias,1)+(cfg.ui.unit_percent||'%');
|
||||
const zForce=zone(Math.max(lp,rp),cfg.thresholds.warning_percent,cfg.thresholds.critical_percent); const zImb=zone(imb,cfg.thresholds.imbalance_warning_percent,cfg.thresholds.imbalance_critical_percent);
|
||||
const statusEl=document.getElementById('status-text'); const reasonEl=document.getElementById('status-reason'); const verdict=document.getElementById('verdict-summary'); const detail=document.getElementById('verdict-detail');
|
||||
let verdictText='OK', reason='Production looks stable.'; let level='ok';
|
||||
if(!connected){ verdictText='PLC OFFLINE'; reason='No PLC communication.'; level='critical'; }
|
||||
else if(stale){ verdictText='STALE DATA'; reason='PLC connected, but no fresh values are arriving.'; level='warning'; }
|
||||
else if(zForce==='critical' || zImb==='critical'){ verdictText='CRITICAL'; reason='Force or imbalance reached critical region.'; level='critical'; }
|
||||
else if(zForce==='warning' || zImb==='warning'){ verdictText='WARNING'; reason='Process is above warning thresholds.'; level='warning'; }
|
||||
statusEl.textContent=verdictText; statusEl.className='status '+level; reasonEl.textContent=reason; verdict.textContent=verdictText; verdict.className='status '+level; detail.textContent=reason;
|
||||
document.getElementById('stale-pill').textContent='Data freshness: '+(stale?'stale':connected?'fresh':'offline');
|
||||
document.getElementById('trend-force').textContent=((Number(t.force_delta_pct)||0)>=0?'+':'')+fmt(t.force_delta_pct,1)+(cfg.ui.unit_percent||'%');
|
||||
document.getElementById('trend-force').className='value mono '+(((Number(t.force_delta_pct)||0)>=3)?'warning':'ok');
|
||||
document.getElementById('trend-force-sub').textContent=(t.force_direction||'--')+' over 15m';
|
||||
document.getElementById('trend-stability').textContent=String(t.process_stability||'--').toUpperCase();
|
||||
document.getElementById('trend-stability').className='value mono '+(t.process_stability==='unstable'?'critical':t.process_stability==='caution'?'warning':'ok');
|
||||
document.getElementById('trend-stability-sub').textContent=t.stability_reason||'No trend reason';
|
||||
const attention=[]; if(!connected) attention.push('Restore PLC communication to recover live monitoring.'); if(stale) attention.push('Investigate stale data path between PLC and the app.'); if(zForce!=='ok') attention.push('Force level is '+zForce+'; review current load and top-force causes.'); if(zImb!=='ok') attention.push('Imbalance is '+zImb+'; check centering, alignment, and tooling.');
|
||||
(a.events||[]).slice(0,4).forEach(ev=>{if(ev.severity!=='info') attention.push((ev.time||'')+' • '+(ev.message||''));});
|
||||
const ul=document.getElementById('attention-list'); ul.innerHTML=''; (attention.length?attention:['No active attention items.']).forEach(item=>{const li=document.createElement('li'); li.textContent=item; ul.appendChild(li);});
|
||||
const banner=document.getElementById('alarm-banner'); if(level==='critical'){banner.textContent='Critical attention required — review force, imbalance, or PLC connectivity.'; banner.classList.add('show');} else if(level==='warning'){banner.textContent='Warning condition active — process should be reviewed.'; banner.classList.add('show');} else {banner.classList.remove('show');}
|
||||
}catch(err){console.warn(err)}
|
||||
}
|
||||
AppUI.initTheme(); AppUI.initFullscreen({ buttonId:'fullscreen-btn' });
|
||||
loadCfg().then(refreshAll); setInterval(refreshAll, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -81,10 +81,13 @@
|
|||
<a class="btn" href="/">Dashboard</a>
|
||||
<a class="btn" href="/history">History</a>
|
||||
<a class="btn" href="/alarms">Alarms</a>
|
||||
<a class="btn" href="/kiosk">Kiosk</a>
|
||||
<a class="btn" href="/process-capability">Process capability</a>
|
||||
<a class="btn" href="/reports">Reports</a>
|
||||
<a class="btn primary" href="/license">License</a>
|
||||
<a class="btn" href="/licence">Licence alias</a>
|
||||
<div class="spacer"></div>
|
||||
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
||||
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
||||
<button id="refresh-btn" class="btn warn" type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -157,6 +160,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let activationRequestText = '';
|
||||
|
||||
|
|
@ -231,27 +235,25 @@
|
|||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/license/status', { cache:'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const status = await res.json();
|
||||
const status = await AppUI.fetchJson('/api/license/status', { timeoutMs:8000 });
|
||||
renderStatus(status);
|
||||
} catch (err) {
|
||||
console.warn('License status error:', err);
|
||||
setMessage('Could not load license status.', false);
|
||||
document.getElementById('metric-mode').textContent = 'ERROR';
|
||||
document.getElementById('metric-mode-sub').textContent = err && err.message ? err.message : 'Could not load license status.';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRequest() {
|
||||
try {
|
||||
const res = await fetch('/api/license/request', { cache:'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const data = await AppUI.fetchJson('/api/license/request', { timeoutMs:8000 });
|
||||
activationRequestText = JSON.stringify(data, null, 2);
|
||||
document.getElementById('request-json').textContent = activationRequestText;
|
||||
} catch (err) {
|
||||
console.warn('License request error:', err);
|
||||
activationRequestText = '';
|
||||
document.getElementById('request-json').textContent = 'Could not load activation request.';
|
||||
document.getElementById('request-json').textContent = 'Could not load activation request: ' + (err && err.message ? err.message : 'unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,8 +322,8 @@
|
|||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
initTheme();
|
||||
document.getElementById('theme-toggle').addEventListener('click', () => setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light'));
|
||||
AppUI.initTheme();
|
||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||
document.getElementById('refresh-btn').addEventListener('click', async () => { setMessage('', true); await Promise.all([refreshStatus(), refreshRequest()]); });
|
||||
document.getElementById('activate-btn').addEventListener('click', activateLicense);
|
||||
document.getElementById('copy-request-btn').addEventListener('click', copyRequest);
|
||||
|
|
|
|||
29
static/process-capability.html
Normal file
29
static/process-capability.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Force Monitor — Process Capability</title>
|
||||
<script src="/static/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root{--bg1:#050816;--bg2:#0b1224;--panel:rgba(255,255,255,.055);--border:rgba(255,255,255,.1);--text:#f4f4f5;--muted:#a1a1aa;--muted2:#71717a;--btnbg:rgba(255,255,255,.05);--good:#34d399;--warn:#facc15;--bad:#f87171;--info:#93c5fd;}body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.1);--text:#0f172a;--muted:#475569;--muted2:#64748b;--btnbg:rgba(255,255,255,.88);--good:#059669;--warn:#b45309;--bad:#dc2626;--info:#1d4ed8;}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 10% 10%, rgba(34,211,238,.12), transparent 18%),radial-gradient(circle at 90% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}.wrap{width:min(95vw,1720px);margin:0 auto;padding:24px}.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}.nav,.row,.toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}.btn,.input{min-height:42px;border-radius:14px;border:1px solid var(--border);background:var(--btnbg);color:var(--text);padding:10px 14px;font:inherit}.btn{text-decoration:none;font-weight:600;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.btn.primary{background:rgba(14,165,233,.14);border-color:rgba(14,165,233,.35)}.page{padding:20px 22px;margin-bottom:18px}.kicker{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted2)}.sub{color:var(--muted);margin-top:8px}.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:18px}.card{padding:18px 20px}.value{font-size:34px;font-weight:800;margin-top:8px}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.chart-box{height:360px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left}th{font-size:12px;color:var(--muted2);text-transform:uppercase;letter-spacing:.16em}.pill{display:inline-flex;align-items:center;justify-content:center;min-width:88px;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid var(--border)}.good{color:var(--good)}.warning{color:var(--warn)}.critical{color:var(--bad)}@media(max-width:1080px){.chart-grid{grid-template-columns:1fr}.wrap{padding:16px}}</style></head>
|
||||
<body data-theme="dark"><div class="wrap">
|
||||
<div class="nav" style="margin-bottom:18px"><a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn primary" href="/process-capability">Process capability</a><a class="btn" href="/reports">Reports</a><a class="btn" href="/license">License</a><div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button></div>
|
||||
<div class="glass page"><div class="row"><div><div class="kicker">Engineering capability</div><h1 style="margin:8px 0 0;font-size:42px;line-height:1.04">Process Capability & Distribution</h1><div class="sub">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.</div></div><div class="spacer"></div><div class="mono sub" id="window-label">Window: --</div></div></div>
|
||||
<div class="glass page"><div class="toolbar"><button class="btn primary window-btn" data-window="1h">1h</button><button class="btn window-btn" data-window="8h">8h</button><button class="btn window-btn" data-window="24h">24h</button><button class="btn window-btn" data-window="7d">7d</button><input id="custom-window" class="input" style="width:140px" placeholder="e.g. 3h or 2d"><button id="apply-window" class="btn" type="button">Apply</button><div class="spacer"></div><button id="refresh-btn" class="btn" type="button">Refresh</button></div></div>
|
||||
<div class="grid cards"><div class="glass card"><div class="kicker">Total Cpk @ critical</div><div id="total-cpk-critical" class="value mono">--</div><div id="total-cpk-sub" class="sub">Capability versus critical load limit</div></div><div class="glass card"><div class="kicker">Imbalance Cpk @ critical</div><div id="imb-cpk-critical" class="value mono">--</div><div id="imb-cpk-sub" class="sub">Capability versus critical imbalance limit</div></div><div class="glass card"><div class="kicker">Left ↔ right correlation</div><div id="corr-value" class="value mono">--</div><div class="sub">Closer to 1.00 means both sides move together</div></div><div class="glass card"><div class="kicker">Suggested action</div><div id="action-pill" class="value" style="font-size:28px">--</div><div id="action-text" class="sub">Loading capability guidance…</div></div></div>
|
||||
<div class="chart-grid"><div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Total force distribution</h2><div class="spacer"></div><span class="pill good">histogram</span></div><div class="sub">Distribution of total peak force against configured warning and critical boundaries.</div><div class="chart-box"><canvas id="totalHist"></canvas></div></div><div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Imbalance distribution</h2><div class="spacer"></div><span class="pill warning">histogram</span></div><div class="sub">Distribution of imbalance magnitude. A tight distribution below warning is usually what engineering wants.</div><div class="chart-box"><canvas id="imbHist"></canvas></div></div></div>
|
||||
<div class="grid cards"><div class="glass card"><div class="kicker">Mean / σ total</div><div id="mean-total" class="value mono">--</div><div id="mean-total-sub" class="sub">P95 / P99 and warning occupancy</div></div><div class="glass card"><div class="kicker">Mean / σ imbalance</div><div id="mean-imb" class="value mono">--</div><div id="mean-imb-sub" class="sub">P95 and critical occupancy</div></div><div class="glass card"><div class="kicker">CPU warning / critical</div><div id="cpu-total" class="value mono">--</div><div class="sub">One-sided capability against upper limits</div></div><div class="glass card"><div class="kicker">Stability</div><div id="stability" class="value mono">--</div><div id="stability-sub" class="sub">Loading…</div></div></div>
|
||||
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Top outliers</h2><div class="spacer"></div><span class="pill critical">review points</span></div><div class="sub">Combined overload and imbalance stress points worth engineering review.</div><div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Time</th><th>Total %</th><th>Total kN</th><th>L %</th><th>R %</th><th>Imb %</th></tr></thead><tbody id="outlier-body"><tr><td colspan="6">No data</td></tr></tbody></table></div></div>
|
||||
</div>
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let currentWindow='1h', cfg={ui:{title:'Force Monitor',unit_force:'kN',unit_percent:'%'},thresholds:{warning_percent:80,critical_percent:95,imbalance_warning_percent:10,imbalance_critical_percent:20}}, totalChart=null, imbChart=null;
|
||||
function fmt(n,d=2){return Number(n||0).toFixed(d)} function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} document.getElementById('theme-toggle').textContent=theme==='light'?'Dark theme':'Light theme'; if(totalChart){updateChartTheme();}}
|
||||
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark')} function updateFullscreenButton(){const btn=document.getElementById('fullscreen-toggle'); if(btn) btn.textContent=document.fullscreenElement?'Exit fullscreen':'Enter fullscreen';} async function toggleFullscreen(){try{if(!document.fullscreenElement){await document.documentElement.requestFullscreen();}else{await document.exitFullscreen();}}catch(e){console.warn('Fullscreen error:',e)}finally{updateFullscreenButton();}}
|
||||
function updateChartTheme(){const light=document.body.dataset.theme==='light'; [totalChart,imbChart].forEach(ch=>{ if(!ch) return; ch.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.update('none');});}
|
||||
function makeHistChart(id,label,color){return new Chart(document.getElementById(id),{type:'bar',data:{labels:[],datasets:[{label:label,borderColor:color,backgroundColor:color+'55',data:[]}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}}}}});}
|
||||
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
|
||||
async function refresh(){const r=await fetch('/api/process-capability?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); document.getElementById('window-label').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('total-cpk-critical').textContent=fmt(d.total_cpk_critical); document.getElementById('total-cpk-sub').textContent='Warning Cpk '+fmt(d.total_cpk_warning)+' • critical occupancy '+fmt(d.total_above_critical_pct,1)+'%'; document.getElementById('imb-cpk-critical').textContent=fmt(d.imbalance_cpk_critical); document.getElementById('imb-cpk-sub').textContent='Warning Cpk '+fmt(d.imbalance_cpk_warning)+' • critical occupancy '+fmt(d.imbalance_above_critical_pct,1)+'%'; document.getElementById('corr-value').textContent=fmt(d.left_right_correlation,3); document.getElementById('action-pill').textContent=(d.total_cpk_critical<1 || d.imbalance_cpk_critical<1)?'ACT':'OK'; document.getElementById('action-pill').className='value '+((d.total_cpk_critical<1 || d.imbalance_cpk_critical<1)?'critical':'good'); document.getElementById('action-text').textContent=d.suggested_action||'--'; document.getElementById('mean-total').textContent=fmt(d.total_mean_pct,1)+(cfg.ui.unit_percent||'%')+' / '+fmt(d.total_std_pct,2); document.getElementById('mean-total-sub').textContent='P95 '+fmt(d.total_p95_pct,1)+' • P99 '+fmt(d.total_p99_pct,1)+' • above warn '+fmt(d.total_above_warning_pct,1)+'%'; document.getElementById('mean-imb').textContent=fmt(d.imbalance_mean_pct,1)+(cfg.ui.unit_percent||'%')+' / '+fmt(d.imbalance_std_pct,2); document.getElementById('mean-imb-sub').textContent='P95 '+fmt(d.imbalance_p95_pct,1)+' • above warn '+fmt(d.imbalance_above_warning_pct,1)+'%'; document.getElementById('cpu-total').textContent=fmt(d.total_cpu_warning)+' / '+fmt(d.total_cpu_critical); document.getElementById('stability').textContent=String(d.stability||'--').toUpperCase(); document.getElementById('stability').className='value mono '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); document.getElementById('stability-sub').textContent=d.stability_reason||'--';
|
||||
if(!totalChart){ totalChart=makeHistChart('totalHist','Total %','#22d3ee'); imbChart=makeHistChart('imbHist','Imbalance %','#f59e0b'); updateChartTheme();}
|
||||
totalChart.data.labels=(d.total_histogram||[]).map(b=>fmt(b.start,0)+'-'+fmt(b.end,0)); totalChart.data.datasets[0].data=(d.total_histogram||[]).map(b=>b.count); totalChart.update('none'); imbChart.data.labels=(d.imbalance_histogram||[]).map(b=>fmt(b.start,0)+'-'+fmt(b.end,0)); imbChart.data.datasets[0].data=(d.imbalance_histogram||[]).map(b=>b.count); imbChart.update('none');
|
||||
const body=document.getElementById('outlier-body'); const rows=(d.top_outliers||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td></tr>').join(''); body.innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
|
||||
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
|
||||
AppUI.initTheme({ onChange: ()=>{ if(totalChart||imbChart) updateChartTheme(); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); loadCfg().then(()=>refresh().catch(console.warn));
|
||||
</script></body></html>
|
||||
25
static/reports.html
Normal file
25
static/reports.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Force Monitor — Reports</title>
|
||||
<script src="/static/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root{--bg1:#050816;--bg2:#0b1224;--panel:rgba(255,255,255,.055);--border:rgba(255,255,255,.1);--text:#f4f4f5;--muted:#a1a1aa;--muted2:#71717a;--btnbg:rgba(255,255,255,.05);--good:#34d399;--warn:#facc15;--bad:#f87171;--info:#93c5fd;}body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.1);--text:#0f172a;--muted:#475569;--muted2:#64748b;--btnbg:rgba(255,255,255,.88);--good:#059669;--warn:#b45309;--bad:#dc2626;--info:#1d4ed8;}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 10% 10%, rgba(34,211,238,.12), transparent 18%),radial-gradient(circle at 90% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}.wrap{width:min(95vw,1760px);margin:0 auto;padding:24px}.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}.nav,.row,.toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}.btn,.input{min-height:42px;border-radius:14px;border:1px solid var(--border);background:var(--btnbg);color:var(--text);padding:10px 14px;font:inherit}.btn{text-decoration:none;font-weight:600;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.btn.primary{background:rgba(14,165,233,.14);border-color:rgba(14,165,233,.35)}.page{padding:20px 22px;margin-bottom:18px}.kicker{font-size:11px;letter-spacing:.22em;color:var(--muted2);text-transform:uppercase}.sub{color:var(--muted);margin-top:8px}.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:18px}.card{padding:18px 20px}.value{font-size:34px;font-weight:800;margin-top:8px}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.chart-box{height:420px}.list{margin:10px 0 0;padding-left:18px}.list li{margin:8px 0;color:var(--muted)}.pill{display:inline-flex;align-items:center;justify-content:center;min-width:88px;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid var(--border)}.good{color:var(--good)}.warning{color:var(--warn)}.critical{color:var(--bad)}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left}th{font-size:12px;color:var(--muted2);text-transform:uppercase;letter-spacing:.16em}@media(max-width:1080px){.wrap{padding:16px}}</style></head>
|
||||
<body data-theme="dark"><div class="wrap">
|
||||
<div class="nav" style="margin-bottom:18px"><a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn" href="/process-capability">Process capability</a><a class="btn primary" href="/reports">Reports</a><a class="btn" href="/license">License</a><div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button></div>
|
||||
<div class="glass page"><div class="row"><div><div class="kicker">Management & engineering report</div><h1 style="margin:8px 0 0;font-size:42px;line-height:1.04">Shift, Day & Week Reports</h1><div class="sub">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.</div></div><div class="spacer"></div><div class="mono sub" id="report-range">Window: --</div></div></div>
|
||||
<div class="glass page"><div class="toolbar"><button class="btn primary window-btn" data-window="8h">Shift (8h)</button><button class="btn window-btn" data-window="24h">Day</button><button class="btn window-btn" data-window="7d">Week</button><button class="btn window-btn" data-window="30d">Month</button><input id="custom-window" class="input" style="width:160px" placeholder="e.g. 48h or 14d"><button id="apply-window" class="btn" type="button">Apply</button><div class="spacer"></div><button id="refresh-btn" class="btn" type="button">Refresh</button><button id="download-json" class="btn" type="button">Download JSON</button></div></div>
|
||||
<div class="grid cards"><div class="glass card"><div class="kicker">Health score</div><div id="health" class="value mono">--</div><div id="health-sub" class="sub">Availability and event pressure</div></div><div class="glass card"><div class="kicker">Avg / peak total</div><div id="avg-peak" class="value mono">--</div><div id="avg-peak-sub" class="sub">Total force summary</div></div><div class="glass card"><div class="kicker">Avg / peak imbalance</div><div id="avg-imb" class="value mono">--</div><div id="avg-imb-sub" class="sub">Centering summary</div></div><div class="glass card"><div class="kicker">Events</div><div id="events" class="value mono">--</div><div id="events-sub" class="sub">Warnings, criticals, PLC disconnects</div></div></div>
|
||||
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Executive summary</h2><div class="spacer"></div><span id="summary-pill" class="pill good">loading</span></div><div id="executive-summary" class="sub" style="font-size:18px;margin-top:14px">Loading report…</div><ul id="findings" class="list"><li>Loading findings…</li></ul></div>
|
||||
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Bucket trend</h2><div class="spacer"></div><span class="pill">selected period</span></div><div class="sub">Each bucket summarizes average total force, maximum force, and event density inside the selected report window.</div><div class="chart-box"><canvas id="reportChart"></canvas></div></div>
|
||||
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Top peaks in report window</h2><div class="spacer"></div><span class="pill critical">top load moments</span></div><div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Time</th><th>Total %</th><th>Total kN</th><th>Imb %</th><th>L %</th><th>R %</th></tr></thead><tbody id="top-peaks-body"><tr><td colspan="6">No data</td></tr></tbody></table></div></div>
|
||||
</div>
|
||||
<script src="/static/app-common.js"></script>
|
||||
<script>
|
||||
let currentWindow='8h', reportCache=null, cfg={ui:{unit_force:'kN',unit_percent:'%'}}, chart=null;
|
||||
function fmt(n,d=1){return Number(n||0).toFixed(d)} function setTheme(theme){document.body.dataset.theme=theme; try{localStorage.setItem('force-monitor-theme',theme)}catch(e){} document.getElementById('theme-toggle').textContent=theme==='light'?'Dark theme':'Light theme'; if(chart){const light=theme==='light'; chart.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.y1.ticks.color=light?'#334155':'#a1a1aa'; chart.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; chart.options.scales.y1.grid.color='transparent'; chart.update('none');}}
|
||||
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark')} function updateFullscreenButton(){const btn=document.getElementById('fullscreen-toggle'); if(btn) btn.textContent=document.fullscreenElement?'Exit fullscreen':'Enter fullscreen';} async function toggleFullscreen(){try{if(!document.fullscreenElement){await document.documentElement.requestFullscreen();}else{await document.exitFullscreen();}}catch(e){console.warn('Fullscreen error:',e)}finally{updateFullscreenButton();}}
|
||||
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
|
||||
function makeChart(){chart=new Chart(document.getElementById('reportChart'),{type:'bar',data:{labels:[],datasets:[{type:'bar',label:'Avg total %',backgroundColor:'rgba(34,211,238,.55)',borderColor:'#22d3ee',data:[]},{type:'line',label:'Max total %',borderColor:'#f87171',backgroundColor:'rgba(248,113,113,.12)',tension:.18,borderWidth:3,data:[],yAxisID:'y'},{type:'line',label:'Warning+Critical events',borderColor:'#facc15',backgroundColor:'rgba(250,204,21,.10)',tension:.18,borderWidth:3,data:[],yAxisID:'y1'}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y1:{position:'right',grid:{color:'transparent'},ticks:{color:'#a1a1aa'}}}}}); setTheme(document.body.dataset.theme||'dark');}
|
||||
async function refresh(){const r=await fetch('/api/reports/summary?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); reportCache=d; document.getElementById('report-range').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('health').textContent=d.health_score+'/100'; document.getElementById('health').className='value mono '+(d.health_score<70?'critical':d.health_score<85?'warning':'good'); document.getElementById('health-sub').textContent='Availability '+fmt(d.availability_pct,1)+'% • stability '+String(d.stability||'--').toUpperCase(); document.getElementById('avg-peak').textContent=fmt(d.average_total_pct,1)+' / '+fmt(d.peak_total_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-peak-sub').textContent='Avg '+fmt(d.average_total_kn,1)+' '+(cfg.ui.unit_force||'kN')+' • peak '+fmt(d.peak_total_kn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('avg-imb').textContent=fmt(d.average_imbalance_pct,1)+' / '+fmt(d.peak_imbalance_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-imb-sub').textContent='Δ force '+((d.force_delta_pct>=0)?'+':'')+fmt(d.force_delta_pct,1)+' • Δ imb '+((d.imbalance_delta_pct>=0)?'+':'')+fmt(d.imbalance_delta_pct,1); document.getElementById('events').textContent=d.warning_events+' / '+d.critical_events; document.getElementById('events-sub').textContent='Warnings / criticals • PLC disconnects '+d.plc_disconnects; document.getElementById('executive-summary').textContent=d.executive_summary||'--'; document.getElementById('summary-pill').textContent=String(d.stability||'stable').toUpperCase(); document.getElementById('summary-pill').className='pill '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); const findings=document.getElementById('findings'); findings.innerHTML=''; (d.findings||[]).forEach(item=>{const li=document.createElement('li'); li.textContent=item; findings.appendChild(li);}); if(!chart) makeChart(); chart.data.labels=(d.buckets||[]).map(b=>b.label); chart.data.datasets[0].data=(d.buckets||[]).map(b=>b.avg_total_pct); chart.data.datasets[1].data=(d.buckets||[]).map(b=>b.max_total_pct); chart.data.datasets[2].data=(d.buckets||[]).map(b=>(b.warning_events||0)+(b.critical_events||0)); chart.update('none'); const rows=(d.top_peaks||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td></tr>').join(''); document.getElementById('top-peaks-body').innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
|
||||
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
|
||||
AppUI.initTheme({ onChange: ()=>{ if(chart) setTheme(document.body.dataset.theme || 'dark'); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); document.getElementById('download-json').addEventListener('click',()=>{ if(!reportCache) return; const blob=new Blob([JSON.stringify(reportCache,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='force-monitor-report-'+currentWindow+'.json'; a.click(); URL.revokeObjectURL(a.href);}); loadCfg().then(()=>refresh().catch(console.warn));
|
||||
</script></body></html>
|
||||
Loading…
Reference in a new issue