Compare commits
No commits in common. "main" and "1.0.6" have entirely different histories.
|
|
@ -1,14 +0,0 @@
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN useradd -r -m -d /app appuser
|
|
||||||
|
|
||||||
COPY activator-force-monitor /app/activator-force-monitor
|
|
||||||
RUN chmod +x /app/activator-force-monitor
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8090
|
|
||||||
|
|
||||||
CMD ["/app/activator-force-monitor"]
|
|
||||||
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
||||||
services:
|
|
||||||
tonnage-activator:
|
|
||||||
build: .
|
|
||||||
container_name: tonnage-activator
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8099:8090"
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Config ─────────────────────────────────────────────
|
|
||||||
CONTAINER_ENGINE="${CONTAINER_ENGINE:-}"
|
|
||||||
IMAGE_NAME="${IMAGE_NAME:-license-activator}"
|
|
||||||
CONTAINER_NAME="${CONTAINER_NAME:-license-activator}"
|
|
||||||
HOST_PORT="${HOST_PORT:-8090}"
|
|
||||||
|
|
||||||
# ── Detect Docker / Podman ─────────────────────────────
|
|
||||||
if [ -z "$CONTAINER_ENGINE" ]; then
|
|
||||||
if command -v docker &>/dev/null; then
|
|
||||||
CONTAINER_ENGINE=docker
|
|
||||||
elif command -v podman &>/dev/null; then
|
|
||||||
CONTAINER_ENGINE=podman
|
|
||||||
else
|
|
||||||
echo "Error: neither docker nor podman found. Install one first." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "Using container engine: $CONTAINER_ENGINE"
|
|
||||||
|
|
||||||
# ── Check daemon is running ────────────────────────────
|
|
||||||
if ! $CONTAINER_ENGINE info >/dev/null 2>&1; then
|
|
||||||
echo "Error: $CONTAINER_ENGINE daemon is not running or you lack permissions." >&2
|
|
||||||
echo " Start the daemon or add your user to the docker/podman group." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Stop & remove existing container ───────────────────
|
|
||||||
if $CONTAINER_ENGINE ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}$"; then
|
|
||||||
echo ""
|
|
||||||
echo "Container '$CONTAINER_NAME' already exists."
|
|
||||||
echo " → Stopping..."
|
|
||||||
$CONTAINER_ENGINE stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
|
||||||
echo " → Removing..."
|
|
||||||
$CONTAINER_ENGINE rm "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
|
||||||
echo " → Old container cleaned up."
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "No existing container '$CONTAINER_NAME' found."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Check if host port is already in use (by something else) ──
|
|
||||||
check_port_in_use() {
|
|
||||||
local port="$1"
|
|
||||||
if command -v ss &>/dev/null; then
|
|
||||||
ss -tln | awk '{print $4}' | grep -Eq ":${port}$"
|
|
||||||
elif command -v netstat &>/dev/null; then
|
|
||||||
netstat -tln 2>/dev/null | awk '{print $4}' | grep -Eq ":${port}$"
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if check_port_in_use "$HOST_PORT"; then
|
|
||||||
echo "Error: port $HOST_PORT is already listening on this host (another process)." >&2
|
|
||||||
echo " Use a different port: HOST_PORT=8080 ./install.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Locate main.go ─────────────────────────────────────
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
MAIN_GO="$SCRIPT_DIR/main.go"
|
|
||||||
|
|
||||||
if [ ! -f "$MAIN_GO" ]; then
|
|
||||||
echo "Error: main.go not found in $SCRIPT_DIR" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Prepare build context ──────────────────────────────
|
|
||||||
BUILD_DIR=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$BUILD_DIR"' EXIT
|
|
||||||
cp "$MAIN_GO" "$BUILD_DIR/"
|
|
||||||
|
|
||||||
cat > "$BUILD_DIR/Dockerfile" <<'EOF'
|
|
||||||
FROM golang:1.23-alpine AS builder
|
|
||||||
WORKDIR /build
|
|
||||||
COPY main.go .
|
|
||||||
RUN go mod init license-activator && go mod tidy
|
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o activator main.go
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
RUN apk add --no-cache ca-certificates
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /build/activator .
|
|
||||||
EXPOSE 8090
|
|
||||||
ENTRYPOINT ["./activator"]
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Build ──────────────────────────────────────────────
|
|
||||||
echo "Building image $IMAGE_NAME:latest..."
|
|
||||||
$CONTAINER_ENGINE build -t "$IMAGE_NAME:latest" "$BUILD_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Run ────────────────────────────────────────────────
|
|
||||||
echo "Starting container '$CONTAINER_NAME' on port $HOST_PORT..."
|
|
||||||
$CONTAINER_ENGINE run -d \
|
|
||||||
--name "$CONTAINER_NAME" \
|
|
||||||
-p "${HOST_PORT}:8090" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
"$IMAGE_NAME:latest"
|
|
||||||
|
|
||||||
# ── Wait a moment for container to start ───────────────
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# ── Show container status ──────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
echo " CONTAINER STATUS"
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
|
|
||||||
$CONTAINER_ENGINE ps --filter "name=^${CONTAINER_NAME}$" --format \
|
|
||||||
" Name: {{.Names}}
|
|
||||||
Image: {{.Image}}
|
|
||||||
Status: {{.Status}}
|
|
||||||
State: {{.State}}
|
|
||||||
Ports: {{.Ports}}
|
|
||||||
Created: {{.CreatedAt}}
|
|
||||||
Command: {{.Command}}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
echo " HEALTH CHECK"
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
|
|
||||||
if curl -sf http://localhost:${HOST_PORT}/api/health >/dev/null 2>&1; then
|
|
||||||
echo " ✅ Health endpoint responding on http://localhost:${HOST_PORT}/api/health"
|
|
||||||
curl -s http://localhost:${HOST_PORT}/api/health | sed 's/^/ /'
|
|
||||||
else
|
|
||||||
echo " ⚠️ Health endpoint not responding yet (may need a few seconds)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
echo " QUICK COMMANDS"
|
|
||||||
echo "═══════════════════════════════════════════════════════"
|
|
||||||
echo " Open app: http://localhost:${HOST_PORT}"
|
|
||||||
echo " View logs: $CONTAINER_ENGINE logs -f $CONTAINER_NAME"
|
|
||||||
echo " Stop: $CONTAINER_ENGINE stop $CONTAINER_NAME"
|
|
||||||
echo " Remove: $CONTAINER_ENGINE rm -f $CONTAINER_NAME"
|
|
||||||
echo " Shell inside: $CONTAINER_ENGINE exec -it $CONTAINER_NAME sh"
|
|
||||||
echo ""
|
|
||||||
44
config.yaml
44
config.yaml
|
|
@ -1,7 +1,7 @@
|
||||||
server:
|
server:
|
||||||
listen_addr: :8080
|
listen_addr: :8080
|
||||||
plc:
|
plc:
|
||||||
ip: 192.168.1.205
|
ip: 192.168.0.1
|
||||||
db_num: 1001
|
db_num: 1001
|
||||||
rack: 0
|
rack: 0
|
||||||
slot: 1
|
slot: 1
|
||||||
|
|
@ -11,52 +11,16 @@ plc:
|
||||||
reconnect_delay_sec: 5
|
reconnect_delay_sec: 5
|
||||||
thresholds:
|
thresholds:
|
||||||
warning_percent: 80
|
warning_percent: 80
|
||||||
critical_percent: 95
|
critical_percent: 100
|
||||||
gauge_max_percent: 130
|
gauge_max_percent: 130
|
||||||
imbalance_warning_percent: 15
|
|
||||||
imbalance_critical_percent: 25
|
|
||||||
trend:
|
trend:
|
||||||
minutes: 5
|
minutes: 5
|
||||||
press:
|
press:
|
||||||
MAX_TONNAGE: 320
|
MAX_TONNAGE: 63
|
||||||
ui:
|
ui:
|
||||||
title: Force Monitor
|
title: Force Monitor
|
||||||
subtitle: Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE
|
subtitle: Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE
|
||||||
left_label: LEVI STEBER
|
left_label: LEVI STEBER
|
||||||
right_label: DESNI STEBER
|
right_label: DESNI STEBER
|
||||||
unit_force: kN
|
unit_force: kN
|
||||||
unit_percent: '%'
|
unit_percent: '%'
|
||||||
modules:
|
|
||||||
show_header_controls: true
|
|
||||||
show_verdict: false
|
|
||||||
show_summary_bar: true
|
|
||||||
show_overview: true
|
|
||||||
show_intelligence: false
|
|
||||||
show_alarm_timeline: false
|
|
||||||
show_gauges: true
|
|
||||||
show_gauge_digital: false
|
|
||||||
show_trend_chart: true
|
|
||||||
db:
|
|
||||||
path: force_monitor.db
|
|
||||||
busy_timeout_ms: 5000
|
|
||||||
batch_size: 32
|
|
||||||
flush_interval_ms: 1000
|
|
||||||
retention_days: 30
|
|
||||||
max_chart_points: 2000
|
|
||||||
writer_queue_size: 4096
|
|
||||||
alarm_queue_size: 512
|
|
||||||
checkpoint_pages: 1000
|
|
||||||
cleanup_interval_hours: 6
|
|
||||||
mqtt:
|
|
||||||
enabled: true
|
|
||||||
broker: tcp://192.168.1.1:1883
|
|
||||||
client_id: force_monitor
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
topic_prefix: force_monitor
|
|
||||||
qos: 1
|
|
||||||
retain: false
|
|
||||||
auto_publish: true
|
|
||||||
publish_interval_ms: 1000
|
|
||||||
connect_timeout_sec: 10
|
|
||||||
reconnect_delay_sec: 5
|
|
||||||
|
|
|
||||||
645
licence.go
645
licence.go
|
|
@ -1,645 +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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const embeddedLicensePublicKeyBase64 = "k0k+ZtOpDWTyO8+uJY9+yL2S/ZzOxyBbaUldw1SJDGc="
|
|
||||||
|
|
||||||
var embeddedLicensePolicy = LicenseConfig{
|
|
||||||
Enabled: true,
|
|
||||||
TrialDays: 7,
|
|
||||||
RequireAfterTrial: true,
|
|
||||||
DataDir: "license",
|
|
||||||
PublicKeyBase64: embeddedLicensePublicKeyBase64,
|
|
||||||
ProductCode: "force_monitor",
|
|
||||||
}
|
|
||||||
|
|
||||||
func runtimeLicenseConfig() LicenseConfig {
|
|
||||||
return embeddedLicensePolicy
|
|
||||||
}
|
|
||||||
|
|
||||||
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 the embedded verifier public key")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
m.active = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The private signing key should live only in a separate offline signer tool.
|
|
||||||
// This app intentionally does not include any signing helper.
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
180
main.go
180
main.go
|
|
@ -36,23 +36,23 @@ import (
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var embeddedStaticFiles embed.FS
|
var embeddedStaticFiles embed.FS
|
||||||
|
|
||||||
const version = "1.0.8"
|
const version = "1.0.6"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config structs
|
// Config structs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
PLC PLCConfig `yaml:"plc"`
|
PLC PLCConfig `yaml:"plc"`
|
||||||
Thresholds ThresholdsConfig `yaml:"thresholds"`
|
Thresholds ThresholdsConfig `yaml:"thresholds"`
|
||||||
Trend TrendConfig `yaml:"trend"`
|
Trend TrendConfig `yaml:"trend"`
|
||||||
Press PressConfig `yaml:"press"`
|
Press PressConfig `yaml:"press"`
|
||||||
UI UIConfig `yaml:"ui"`
|
UI UIConfig `yaml:"ui"`
|
||||||
Modules ModulesConfig `yaml:"modules"`
|
Modules ModulesConfig `yaml:"modules"`
|
||||||
DB DBConfig `yaml:"db"`
|
DB DBConfig `yaml:"db"`
|
||||||
MQTT MQTTConfig `yaml:"mqtt"`
|
MQTT MQTTConfig `yaml:"mqtt"`
|
||||||
LegacyLicense *LicenseConfig `yaml:"license,omitempty"`
|
License LicenseConfig `yaml:"license"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
|
|
@ -218,6 +218,14 @@ func defaultConfig() Config {
|
||||||
ConnectTimeoutSec: 10,
|
ConnectTimeoutSec: 10,
|
||||||
ReconnectDelaySec: 5,
|
ReconnectDelaySec: 5,
|
||||||
},
|
},
|
||||||
|
License: LicenseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TrialDays: 7,
|
||||||
|
RequireAfterTrial: true,
|
||||||
|
DataDir: "license",
|
||||||
|
PublicKeyBase64: "",
|
||||||
|
ProductCode: "force_monitor",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,6 +335,12 @@ func normalizeConfig(cfg *Config) {
|
||||||
setIfZeroI(&cfg.MQTT.ReconnectDelaySec, def.MQTT.ReconnectDelaySec)
|
setIfZeroI(&cfg.MQTT.ReconnectDelaySec, def.MQTT.ReconnectDelaySec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !cfg.License.Enabled {
|
||||||
|
// keep defaults when disabled, but still normalize product code if provided
|
||||||
|
}
|
||||||
|
setIfZeroI(&cfg.License.TrialDays, def.License.TrialDays)
|
||||||
|
setIfEmpty(&cfg.License.DataDir, def.License.DataDir)
|
||||||
|
setIfEmpty(&cfg.License.ProductCode, def.License.ProductCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfigStrict(configPath string) (Config, error) {
|
func loadConfigStrict(configPath string) (Config, error) {
|
||||||
|
|
@ -778,13 +792,12 @@ type mqttManager struct {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg Config
|
cfg Config
|
||||||
cfgMu sync.RWMutex
|
cfgMu sync.RWMutex
|
||||||
state AppState
|
state AppState
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
sampleCh chan Sample
|
sampleCh chan Sample
|
||||||
alarmCh chan AlarmEvent
|
alarmCh chan AlarmEvent
|
||||||
indexTmpl *template.Template
|
|
||||||
|
|
||||||
alarmTracker AlarmTracker
|
alarmTracker AlarmTracker
|
||||||
uiRevision uint64 = 1
|
uiRevision uint64 = 1
|
||||||
|
|
@ -1021,9 +1034,6 @@ func startMQTTPublisher(ctx context.Context) {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if !licenseAllowsRuntime() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s := snapshotState()
|
s := snapshotState()
|
||||||
|
|
||||||
full, err := json.Marshal(s)
|
full, err := json.Marshal(s)
|
||||||
|
|
@ -1146,6 +1156,9 @@ func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartS
|
||||||
if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
|
if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
|
||||||
restartSections = append(restartSections, "mqtt")
|
restartSections = append(restartSections, "mqtt")
|
||||||
}
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.License, newCfg.License) {
|
||||||
|
restartSections = append(restartSections, "license")
|
||||||
|
}
|
||||||
return hotSections, restartSections
|
return hotSections, restartSections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1930,15 +1943,6 @@ func startPLCPoller(ctx context.Context) {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if !licenseAllowsRuntime() {
|
|
||||||
markDisconnected("license locked")
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
|
handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
|
||||||
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
||||||
|
|
@ -1970,11 +1974,6 @@ func startPLCPoller(ctx context.Context) {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if !licenseAllowsRuntime() {
|
|
||||||
markDisconnected("license locked")
|
|
||||||
_ = handler.Close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
|
if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
|
||||||
readErrCount++
|
readErrCount++
|
||||||
|
|
@ -2831,12 +2830,11 @@ func queryReportSummary(ctx context.Context, window time.Duration, label string)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ReportSummaryResponse{}, err
|
return ReportSummaryResponse{}, err
|
||||||
}
|
}
|
||||||
defer alarmRows.Close()
|
|
||||||
|
|
||||||
for alarmRows.Next() {
|
for alarmRows.Next() {
|
||||||
var tsUnix int64
|
var tsUnix int64
|
||||||
var severity, source, code string
|
var severity, source, code string
|
||||||
if err := alarmRows.Scan(&tsUnix, &severity, &source, &code); err != nil {
|
if err := alarmRows.Scan(&tsUnix, &severity, &source, &code); err != nil {
|
||||||
|
alarmRows.Close()
|
||||||
return ReportSummaryResponse{}, err
|
return ReportSummaryResponse{}, err
|
||||||
}
|
}
|
||||||
labelKey := reportBucketLabel(time.Unix(0, tsUnix), window)
|
labelKey := reportBucketLabel(time.Unix(0, tsUnix), window)
|
||||||
|
|
@ -2850,9 +2848,7 @@ func queryReportSummary(ctx context.Context, window time.Duration, label string)
|
||||||
plcDiscByBucket[labelKey]++
|
plcDiscByBucket[labelKey]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := alarmRows.Err(); err != nil {
|
alarmRows.Close()
|
||||||
return ReportSummaryResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
buckets := make([]ReportBucket, 0, len(order))
|
buckets := make([]ReportBucket, 0, len(order))
|
||||||
for _, key := range order {
|
for _, key := range order {
|
||||||
|
|
@ -2998,26 +2994,6 @@ func requireActiveLicense(w http.ResponseWriter, r *http.Request) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireActiveLicensePage(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
if licenseMgr == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
status := licenseMgr.Status()
|
|
||||||
if !status.Locked {
|
|
||||||
_ = licenseMgr.Touch()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/license", http.StatusSeeOther)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseAllowsRuntime() bool {
|
|
||||||
if licenseMgr == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return !licenseMgr.Status().Locked
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HTTP handlers — core
|
// HTTP handlers — core
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -3044,7 +3020,6 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c := getConfigSnapshot()
|
c := getConfigSnapshot()
|
||||||
policy := runtimeLicenseConfig()
|
|
||||||
resp := PublicConfigResponse{
|
resp := PublicConfigResponse{
|
||||||
Version: version,
|
Version: version,
|
||||||
UIRevision: atomic.LoadUint64(&uiRevision),
|
UIRevision: atomic.LoadUint64(&uiRevision),
|
||||||
|
|
@ -3054,8 +3029,8 @@ func apiPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
Press: c.Press,
|
Press: c.Press,
|
||||||
Modules: c.Modules,
|
Modules: c.Modules,
|
||||||
LicenseHint: LicenseHint{
|
LicenseHint: LicenseHint{
|
||||||
Enabled: policy.Enabled,
|
Enabled: c.License.Enabled,
|
||||||
TrialDays: policy.TrialDays,
|
TrialDays: c.License.TrialDays,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|
@ -3185,9 +3160,6 @@ func serveAlarmsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requireActiveLicensePage(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveEmbeddedHTMLPage(w, "static/alarms.html")
|
serveEmbeddedHTMLPage(w, "static/alarms.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3199,9 +3171,6 @@ func serveHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requireActiveLicensePage(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveEmbeddedHTMLPage(w, "static/history.html")
|
serveEmbeddedHTMLPage(w, "static/history.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3270,9 +3239,6 @@ func serveKioskPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requireActiveLicensePage(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveEmbeddedHTMLPage(w, "static/kiosk.html")
|
serveEmbeddedHTMLPage(w, "static/kiosk.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3284,9 +3250,6 @@ func serveProcessCapabilityPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requireActiveLicensePage(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveEmbeddedHTMLPage(w, "static/process-capability.html")
|
serveEmbeddedHTMLPage(w, "static/process-capability.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3298,21 +3261,48 @@ func serveReportsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requireActiveLicensePage(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveEmbeddedHTMLPage(w, "static/reports.html")
|
serveEmbeddedHTMLPage(w, "static/reports.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/" {
|
if r.URL.Path == "/" {
|
||||||
if !requireActiveLicensePage(w, r) {
|
// Check license before serving the UI
|
||||||
return
|
if licenseMgr != nil {
|
||||||
|
status := licenseMgr.Status()
|
||||||
|
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>
|
||||||
|
<body><div class="card"><h1>Force Monitor</h1><p><strong>Machine fingerprint:</strong> %s</p>
|
||||||
|
<p><strong>License mode:</strong> %s</p>
|
||||||
|
<p><strong>Message:</strong> %s</p>
|
||||||
|
<p><a href="/api/license/status" style="color:#93c5fd">GET /api/license/status</a></p>
|
||||||
|
<p><a href="/api/license/request" style="color:#93c5fd">GET /api/license/request</a></p>
|
||||||
|
<p><a href="/license" style="color:#93c5fd">Open advanced license page</a></p>
|
||||||
|
<h3>Paste signed license JSON</h3>
|
||||||
|
<textarea id="licenseText" placeholder='{"app":"force_monitor",...}'></textarea>
|
||||||
|
<div style="margin-top:12px"><button onclick="activate()">Activate license</button></div>
|
||||||
|
<pre id="out"></pre>
|
||||||
|
<script>
|
||||||
|
async function activate(){
|
||||||
|
const t = document.getElementById('licenseText').value;
|
||||||
|
const res = await fetch('/api/license/activate', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({license_text:t})});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
document.getElementById('out').textContent = JSON.stringify(j, null, 2);
|
||||||
|
if(j.status === 'activated') setTimeout(() => location.reload(), 800);
|
||||||
|
}
|
||||||
|
</script></div></body></html>`, status.FingerprintShort, status.Mode, status.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// License OK — serve the full dashboard template from the embedded static files
|
// License OK — serve the full dashboard template from the embedded static files
|
||||||
if indexTmpl == nil {
|
tmpl, err := template.ParseFS(embeddedStaticFiles, "static/index.html")
|
||||||
log.Printf("dashboard template not initialized")
|
if err != nil {
|
||||||
|
log.Printf("template parse error: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -3359,7 +3349,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
if err := indexTmpl.Execute(w, data); err != nil {
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
log.Printf("template execute error: %v", err)
|
log.Printf("template execute error: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -3631,14 +3621,6 @@ func main() {
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := validateConfig(cfg); err != nil {
|
||||||
log.Fatalf("invalid config: %v", err)
|
log.Fatalf("invalid config: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.LegacyLicense != nil {
|
|
||||||
log.Printf("config.yaml contains a legacy license section; it is ignored by the embedded offline license policy")
|
|
||||||
}
|
|
||||||
|
|
||||||
indexTmpl, err = template.ParseFS(embeddedStaticFiles, "static/index.html")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to parse embedded dashboard template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbPath := cfg.DB.Path
|
dbPath := cfg.DB.Path
|
||||||
if !filepath.IsAbs(dbPath) {
|
if !filepath.IsAbs(dbPath) {
|
||||||
|
|
@ -3651,12 +3633,11 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
licensePolicy := runtimeLicenseConfig()
|
licenseDataDir := cfg.License.DataDir
|
||||||
licenseDataDir := licensePolicy.DataDir
|
|
||||||
if !filepath.IsAbs(licenseDataDir) {
|
if !filepath.IsAbs(licenseDataDir) {
|
||||||
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
||||||
}
|
}
|
||||||
licenseMgr, err = NewLicenseManager(licensePolicy, licenseDataDir)
|
licenseMgr, err = NewLicenseManager(cfg.License, licenseDataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize license manager: %v", err)
|
log.Fatalf("failed to initialize license manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -3761,12 +3742,11 @@ func main() {
|
||||||
mux.HandleFunc("/api/license/activate", apiLicenseActivate)
|
mux.HandleFunc("/api/license/activate", apiLicenseActivate)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Server.ListenAddr,
|
Addr: cfg.Server.ListenAddr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
ReadTimeout: 15 * time.Second,
|
WriteTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 15 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Listening address: %s", cfg.Server.ListenAddr)
|
log.Printf("Listening address: %s", cfg.Server.ListenAddr)
|
||||||
|
|
|
||||||
|
|
@ -514,7 +514,7 @@
|
||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
|
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
|
||||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||||
AppUI.updateFullscreenButton('fullscreen-toggle');
|
updateFullscreenButton();
|
||||||
qs('refresh-btn').addEventListener('click', refreshAll);
|
qs('refresh-btn').addEventListener('click', refreshAll);
|
||||||
qs('export-csv').addEventListener('click', exportCsv);
|
qs('export-csv').addEventListener('click', exportCsv);
|
||||||
qs('apply-window').addEventListener('click', () => {
|
qs('apply-window').addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@
|
||||||
--button-bg: rgba(255,255,255,0.05);
|
--button-bg: rgba(255,255,255,0.05);
|
||||||
--button-border: rgba(255,255,255,0.10);
|
--button-border: rgba(255,255,255,0.10);
|
||||||
--button-text: #e4e4e7;
|
--button-text: #e4e4e7;
|
||||||
--text: var(--body-text);
|
|
||||||
--border: var(--button-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
@ -40,8 +38,6 @@
|
||||||
--button-bg: rgba(255,255,255,0.88);
|
--button-bg: rgba(255,255,255,0.88);
|
||||||
--button-border: rgba(15,23,42,0.10);
|
--button-border: rgba(15,23,42,0.10);
|
||||||
--button-text: #0f172a;
|
--button-text: #0f172a;
|
||||||
--text: var(--body-text);
|
|
||||||
--border: var(--button-border);
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
|
radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
|
||||||
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
|
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
|
||||||
|
|
@ -88,30 +84,22 @@
|
||||||
body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
|
body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
|
||||||
body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
|
body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
|
||||||
body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }
|
body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 42px;
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--button-border);
|
||||||
background: rgba(255,255,255,.05);
|
background: var(--button-bg);
|
||||||
color: var(--text);
|
color: var(--button-text);
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 160ms ease;
|
transition: 160ms ease;
|
||||||
}
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
body[data-theme="light"] .control-btn {
|
|
||||||
background: rgba(255,255,255,.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.primary {
|
|
||||||
background: rgba(14,165,233,0.14);
|
|
||||||
border-color: rgba(14,165,233,0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn:hover {
|
.control-btn:hover {
|
||||||
|
|
@ -296,7 +284,7 @@
|
||||||
<div class="glass border border-white/10 rounded-3xl p-4 mb-8">
|
<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 items-center justify-between">
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href="/" class="control-btn primary" target="_self">Dashboard</a>
|
<a href="/" class="control-btn" target="_self">Dashboard</a>
|
||||||
<a href="/history" class="control-btn" target="_self">History</a>
|
<a href="/history" class="control-btn" target="_self">History</a>
|
||||||
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
|
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
|
||||||
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
|
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
|
||||||
|
|
@ -1082,6 +1070,54 @@
|
||||||
return n.toFixed(1) + UNIT_PCT;
|
return n.toFixed(1) + UNIT_PCT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
currentTheme = theme === 'light' ? 'light' : 'dark';
|
||||||
|
document.body.setAttribute('data-theme', currentTheme);
|
||||||
|
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (e) {}
|
||||||
|
updateThemeButton();
|
||||||
|
updateChartTheme();
|
||||||
|
redrawGauges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
let theme = 'dark';
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('force-monitor-theme');
|
||||||
|
if (stored === 'light' || stored === 'dark') {
|
||||||
|
theme = stored;
|
||||||
|
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||||
|
theme = 'light';
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() { applyTheme(isLightTheme() ? 'dark' : 'light'); }
|
||||||
|
|
||||||
|
function updateThemeButton() {
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (btn) btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (err) {
|
||||||
|
console.warn('Fullscreen error:', err);
|
||||||
|
} finally {
|
||||||
|
updateFullscreenButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateChartTheme() {
|
function updateChartTheme() {
|
||||||
if (!SHOW_TREND_CHART || !lineChart) return;
|
if (!SHOW_TREND_CHART || !lineChart) return;
|
||||||
const light = isLightTheme();
|
const light = isLightTheme();
|
||||||
|
|
@ -1317,21 +1353,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
AppUI.initTheme({
|
initTheme();
|
||||||
buttonId: 'theme-toggle',
|
|
||||||
onChange: (theme) => {
|
|
||||||
currentTheme = theme;
|
|
||||||
updateChartTheme();
|
|
||||||
redrawGauges();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (SHOW_HEADER_CONTROLS) {
|
|
||||||
AppUI.initFullscreen({ buttonId: 'fullscreen-toggle' });
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveWindowButton(DEFAULT_WINDOW);
|
setActiveWindowButton(DEFAULT_WINDOW);
|
||||||
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
||||||
|
|
||||||
|
if (SHOW_HEADER_CONTROLS) {
|
||||||
|
const themeBtn = document.getElementById('theme-toggle');
|
||||||
|
const fsBtn = document.getElementById('fullscreen-toggle');
|
||||||
|
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
|
||||||
|
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.window-btn').forEach(btn =>
|
document.querySelectorAll('.window-btn').forEach(btn =>
|
||||||
btn.addEventListener('click', () => useWindow(btn.dataset.window)));
|
btn.addEventListener('click', () => useWindow(btn.dataset.window)));
|
||||||
|
|
||||||
|
|
@ -1362,6 +1394,8 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
||||||
|
updateFullscreenButton();
|
||||||
|
|
||||||
if (SHOW_TREND_CHART) {
|
if (SHOW_TREND_CHART) {
|
||||||
const chartCanvas = document.getElementById('lineChart');
|
const chartCanvas = document.getElementById('lineChart');
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,10 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Force Monitor — Kiosk</title>
|
<title>Force Monitor — Kiosk</title>
|
||||||
<style>
|
<style>
|
||||||
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
|
||||||
: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;}
|
: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;}
|
||||||
body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.10);--text:#0f172a;--muted:#475569;--ok:#059669;--warn:#b45309;--bad:#dc2626;}
|
|
||||||
*{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));}
|
*{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));}
|
||||||
body[data-theme="light"]{background:radial-gradient(circle at 20% 10%, rgba(14,165,233,.10), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.10), 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}
|
.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}
|
.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}
|
||||||
body[data-theme="light"] .btn{background:rgba(255,255,255,.88);}
|
|
||||||
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
|
.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}
|
.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}
|
.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}
|
||||||
|
|
@ -26,7 +22,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="nav" style="margin-bottom:14px">
|
<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 primary" 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>
|
<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 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>
|
||||||
<div id="alarm-banner" class="banner"></div>
|
<div id="alarm-banner" class="banner"></div>
|
||||||
|
|
@ -58,6 +54,9 @@
|
||||||
<script src="/static/app-common.js"></script>
|
<script src="/static/app-common.js"></script>
|
||||||
<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}};
|
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';
|
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 zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
|
||||||
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
|
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
<script src="/static/app-common.js"></script>
|
<script src="/static/app-common.js"></script>
|
||||||
<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;
|
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 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 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'}}}}});}
|
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 loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
<script src="/static/app-common.js"></script>
|
<script src="/static/app-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let currentWindow='8h', reportCache=null, cfg={ui:{unit_force:'kN',unit_percent:'%'}}, chart=null;
|
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){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 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)}}
|
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');}
|
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>';}
|
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>';}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue