Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6bfc94b8 | ||
|
|
b0ed6ac499 | ||
|
|
3899c78a89 | ||
|
|
dc1b30a34e | ||
|
|
ff029e4e81 | ||
|
|
8c4121f32f | ||
|
|
6cba2d15f6 | ||
|
|
e38ecfc037 | ||
|
|
49860df5a0 | ||
|
|
3ca28c0e13 | ||
|
|
8aed4f57e2 | ||
|
|
8c0b353c90 |
14
activator/Dockerfile
Normal file
14
activator/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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.
7
activator/docker-compose.yml
Normal file
7
activator/docker-compose.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
services:
|
||||||
|
tonnage-activator:
|
||||||
|
build: .
|
||||||
|
container_name: tonnage-activator
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8099:8090"
|
||||||
143
activator/install.sh
Executable file
143
activator/install.sh
Executable file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/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.0.1
|
ip: 192.168.1.205
|
||||||
db_num: 1001
|
db_num: 1001
|
||||||
rack: 0
|
rack: 0
|
||||||
slot: 1
|
slot: 1
|
||||||
|
|
@ -11,16 +11,52 @@ plc:
|
||||||
reconnect_delay_sec: 5
|
reconnect_delay_sec: 5
|
||||||
thresholds:
|
thresholds:
|
||||||
warning_percent: 80
|
warning_percent: 80
|
||||||
critical_percent: 100
|
critical_percent: 95
|
||||||
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: 63
|
MAX_TONNAGE: 320
|
||||||
ui:
|
ui:
|
||||||
title: Force Monitor
|
title: Force Monitor
|
||||||
subtitle: Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE
|
subtitle: Siemens S7-1215C • Piezo peak/stroke input • 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
|
||||||
|
|
|
||||||
40
go.sum
40
go.sum
|
|
@ -1,20 +1,20 @@
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
github.com/robinson/gos7 v0.0.0-20241205073040-7ea1d6fb9d20 h1:HjGiMRQ3pKwKH3p0mmLtY62bwd973txhzV9FfpdGo7U=
|
github.com/robinson/gos7 v0.0.0-20241205073040-7ea1d6fb9d20 h1:HjGiMRQ3pKwKH3p0mmLtY62bwd973txhzV9FfpdGo7U=
|
||||||
github.com/robinson/gos7 v0.0.0-20241205073040-7ea1d6fb9d20/go.mod h1:AMHIeh1KJ7Xa2RVOMHdv9jXKrpw0D4EWGGQMHLb2doc=
|
github.com/robinson/gos7 v0.0.0-20241205073040-7ea1d6fb9d20/go.mod h1:AMHIeh1KJ7Xa2RVOMHdv9jXKrpw0D4EWGGQMHLb2doc=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
645
licence.go
Normal file
645
licence.go
Normal file
|
|
@ -0,0 +1,645 @@
|
||||||
|
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.6"
|
const version = "1.0.8"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 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"`
|
||||||
License LicenseConfig `yaml:"license"`
|
LegacyLicense *LicenseConfig `yaml:"license,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
|
|
@ -218,14 +218,6 @@ 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",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,12 +327,6 @@ 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) {
|
||||||
|
|
@ -792,12 +778,13 @@ 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
|
||||||
|
|
@ -1034,6 +1021,9 @@ 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)
|
||||||
|
|
@ -1156,9 +1146,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1943,6 +1930,15 @@ 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
|
||||||
|
|
@ -1974,6 +1970,11 @@ 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++
|
||||||
|
|
@ -2830,11 +2831,12 @@ 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)
|
||||||
|
|
@ -2848,7 +2850,9 @@ func queryReportSummary(ctx context.Context, window time.Duration, label string)
|
||||||
plcDiscByBucket[labelKey]++
|
plcDiscByBucket[labelKey]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alarmRows.Close()
|
if err := alarmRows.Err(); err != nil {
|
||||||
|
return ReportSummaryResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
buckets := make([]ReportBucket, 0, len(order))
|
buckets := make([]ReportBucket, 0, len(order))
|
||||||
for _, key := range order {
|
for _, key := range order {
|
||||||
|
|
@ -2994,6 +2998,26 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -3020,6 +3044,7 @@ 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),
|
||||||
|
|
@ -3029,8 +3054,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: c.License.Enabled,
|
Enabled: policy.Enabled,
|
||||||
TrialDays: c.License.TrialDays,
|
TrialDays: policy.TrialDays,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|
@ -3160,6 +3185,9 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3171,6 +3199,9 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3239,6 +3270,9 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3250,6 +3284,9 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3261,48 +3298,21 @@ 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 == "/" {
|
||||||
// Check license before serving the UI
|
if !requireActiveLicensePage(w, r) {
|
||||||
if licenseMgr != nil {
|
return
|
||||||
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
|
||||||
tmpl, err := template.ParseFS(embeddedStaticFiles, "static/index.html")
|
if indexTmpl == nil {
|
||||||
if err != nil {
|
log.Printf("dashboard template not initialized")
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -3349,7 +3359,7 @@ async function activate(){
|
||||||
|
|
||||||
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 := tmpl.Execute(w, data); err != nil {
|
if err := indexTmpl.Execute(w, data); err != nil {
|
||||||
log.Printf("template execute error: %v", err)
|
log.Printf("template execute error: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -3621,6 +3631,14 @@ 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) {
|
||||||
|
|
@ -3633,11 +3651,12 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
licenseDataDir := cfg.License.DataDir
|
licensePolicy := runtimeLicenseConfig()
|
||||||
|
licenseDataDir := licensePolicy.DataDir
|
||||||
if !filepath.IsAbs(licenseDataDir) {
|
if !filepath.IsAbs(licenseDataDir) {
|
||||||
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
licenseDataDir = filepath.Join(wd, licenseDataDir)
|
||||||
}
|
}
|
||||||
licenseMgr, err = NewLicenseManager(cfg.License, licenseDataDir)
|
licenseMgr, err = NewLicenseManager(licensePolicy, 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)
|
||||||
}
|
}
|
||||||
|
|
@ -3742,11 +3761,12 @@ 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,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Listening address: %s", cfg.Server.ListenAddr)
|
log.Printf("Listening address: %s", cfg.Server.ListenAddr)
|
||||||
|
|
|
||||||
|
|
@ -1,379 +1,379 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<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 — Alarms</title>
|
<title>Force Monitor — Alarms</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg1:#050816; --bg2:#0b1224; --panel:rgba(255,255,255,0.055); --border:rgba(255,255,255,0.10);
|
--bg1:#050816; --bg2:#0b1224; --panel:rgba(255,255,255,0.055); --border:rgba(255,255,255,0.10);
|
||||||
--text:#f4f4f5; --muted:#a1a1aa; --muted2:#71717a; --btnbg:rgba(255,255,255,0.05); --shadow:0 16px 36px rgba(0,0,0,0.18);
|
--text:#f4f4f5; --muted:#a1a1aa; --muted2:#71717a; --btnbg:rgba(255,255,255,0.05); --shadow:0 16px 36px rgba(0,0,0,0.18);
|
||||||
--tableHover:rgba(255,255,255,0.04);
|
--tableHover:rgba(255,255,255,0.04);
|
||||||
}
|
}
|
||||||
body[data-theme="light"] {
|
body[data-theme="light"] {
|
||||||
--bg1:#eef4ff; --bg2:#f8fafc; --panel:rgba(255,255,255,0.84); --border:rgba(15,23,42,0.10);
|
--bg1:#eef4ff; --bg2:#f8fafc; --panel:rgba(255,255,255,0.84); --border:rgba(15,23,42,0.10);
|
||||||
--text:#0f172a; --muted:#475569; --muted2:#64748b; --btnbg:rgba(255,255,255,0.88); --shadow:0 16px 36px rgba(15,23,42,0.08);
|
--text:#0f172a; --muted:#475569; --muted2:#64748b; --btnbg:rgba(255,255,255,0.88); --shadow:0 16px 36px rgba(15,23,42,0.08);
|
||||||
--tableHover:rgba(15,23,42,0.04);
|
--tableHover:rgba(15,23,42,0.04);
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body {
|
body {
|
||||||
margin:0; min-height:100vh; color:var(--text); font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
|
margin:0; min-height:100vh; color:var(--text); font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
||||||
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
||||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||||||
}
|
}
|
||||||
body[data-theme="light"] {
|
body[data-theme="light"] {
|
||||||
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%),
|
||||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||||||
}
|
}
|
||||||
.wrap { width:min(94vw, 1680px); margin:0 auto; padding:24px; }
|
.wrap { width:min(94vw, 1680px); 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); box-shadow:var(--shadow); }
|
.glass { background:var(--panel); border:1px solid var(--border); border-radius:24px; backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); box-shadow:var(--shadow); }
|
||||||
.nav, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
.nav, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
||||||
.nav { margin-bottom:18px; }
|
.nav { margin-bottom:18px; }
|
||||||
.btn, .input, select {
|
.btn, .input, select {
|
||||||
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
|
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
|
||||||
padding:10px 14px; font:inherit;
|
padding:10px 14px; font:inherit;
|
||||||
}
|
}
|
||||||
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
.btn:hover { transform:translateY(-1px); }
|
.btn:hover { transform:translateY(-1px); }
|
||||||
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
||||||
.btn.good { background:rgba(16,185,129,0.14); border-color:rgba(16,185,129,0.35); }
|
.btn.good { background:rgba(16,185,129,0.14); border-color:rgba(16,185,129,0.35); }
|
||||||
.btn.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
|
.btn.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
|
||||||
.input { width:100%; }
|
.input { width:100%; }
|
||||||
.grid { display:grid; gap:16px; }
|
.grid { display:grid; gap:16px; }
|
||||||
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
|
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
|
||||||
.card { padding:18px 20px; }
|
.card { padding:18px 20px; }
|
||||||
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
|
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
|
||||||
.value { font-size:34px; font-weight:800; margin-top:8px; }
|
.value { font-size:34px; font-weight:800; margin-top:8px; }
|
||||||
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
|
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
|
||||||
.pill {
|
.pill {
|
||||||
display:inline-flex; align-items:center; justify-content:center; min-width:86px; padding:4px 10px; border-radius:999px;
|
display:inline-flex; align-items:center; justify-content:center; min-width:86px; padding:4px 10px; border-radius:999px;
|
||||||
font-size:12px; font-weight:700; letter-spacing:.04em; border:1px solid transparent;
|
font-size:12px; font-weight:700; letter-spacing:.04em; border:1px solid transparent;
|
||||||
}
|
}
|
||||||
.pill.info { background:rgba(59,130,246,0.12); color:#93c5fd; border-color:rgba(59,130,246,0.24); }
|
.pill.info { background:rgba(59,130,246,0.12); color:#93c5fd; border-color:rgba(59,130,246,0.24); }
|
||||||
.pill.warning { background:rgba(245,158,11,0.12); color:#fde68a; border-color:rgba(245,158,11,0.24); }
|
.pill.warning { background:rgba(245,158,11,0.12); color:#fde68a; border-color:rgba(245,158,11,0.24); }
|
||||||
.pill.critical { background:rgba(239,68,68,0.12); color:#fca5a5; border-color:rgba(239,68,68,0.24); }
|
.pill.critical { background:rgba(239,68,68,0.12); color:#fca5a5; border-color:rgba(239,68,68,0.24); }
|
||||||
.pill.clear { background:rgba(113,113,122,0.12); color:#d4d4d8; border-color:rgba(113,113,122,0.24); }
|
.pill.clear { background:rgba(113,113,122,0.12); color:#d4d4d8; border-color:rgba(113,113,122,0.24); }
|
||||||
body[data-theme="light"] .pill.info { color:#1d4ed8; }
|
body[data-theme="light"] .pill.info { color:#1d4ed8; }
|
||||||
body[data-theme="light"] .pill.warning { color:#b45309; }
|
body[data-theme="light"] .pill.warning { color:#b45309; }
|
||||||
body[data-theme="light"] .pill.critical { color:#dc2626; }
|
body[data-theme="light"] .pill.critical { color:#dc2626; }
|
||||||
body[data-theme="light"] .pill.clear { color:#52525b; }
|
body[data-theme="light"] .pill.clear { color:#52525b; }
|
||||||
.status-line { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:14px; margin-top:8px; }
|
.status-line { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:14px; margin-top:8px; }
|
||||||
table { width:100%; border-collapse:collapse; }
|
table { width:100%; border-collapse:collapse; }
|
||||||
th, td { padding:12px 10px; text-align:left; border-bottom:1px solid var(--border); vertical-align:top; }
|
th, td { padding:12px 10px; text-align:left; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||||
th { color:var(--muted2); font-size:12px; text-transform:uppercase; letter-spacing:.16em; }
|
th { color:var(--muted2); font-size:12px; text-transform:uppercase; letter-spacing:.16em; }
|
||||||
tbody tr:hover { background:var(--tableHover); }
|
tbody tr:hover { background:var(--tableHover); }
|
||||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
||||||
.right { text-align:right; }
|
.right { text-align:right; }
|
||||||
.toolbar-wrap { padding:18px 20px; margin-bottom:18px; }
|
.toolbar-wrap { padding:18px 20px; margin-bottom:18px; }
|
||||||
.table-wrap { padding:0 0 6px 0; overflow:auto; }
|
.table-wrap { padding:0 0 6px 0; overflow:auto; }
|
||||||
.error, .empty, .hint { color:var(--muted); }
|
.error, .empty, .hint { color:var(--muted); }
|
||||||
.banner {
|
.banner {
|
||||||
display:none; margin-bottom:16px; padding:14px 18px; border-radius:18px;
|
display:none; margin-bottom:16px; padding:14px 18px; border-radius:18px;
|
||||||
background:rgba(239,68,68,0.14); border:1px solid rgba(239,68,68,0.28); color:#fecaca;
|
background:rgba(239,68,68,0.14); border:1px solid rgba(239,68,68,0.28); color:#fecaca;
|
||||||
}
|
}
|
||||||
.banner.show { display:block; }
|
.banner.show { display:block; }
|
||||||
.row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
.row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
||||||
.spacer { flex:1 1 auto; }
|
.spacer { flex:1 1 auto; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.wrap { width:min(96vw, 1680px); padding:16px; }
|
.wrap { width:min(96vw, 1680px); padding:16px; }
|
||||||
.value { font-size:28px; }
|
.value { font-size:28px; }
|
||||||
th:nth-child(5), td:nth-child(5), th:nth-child(6), td:nth-child(6) { display:none; }
|
th:nth-child(5), td:nth-child(5), th:nth-child(6), td:nth-child(6) { display:none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-theme="dark">
|
<body data-theme="dark">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a class="btn" href="/">Dashboard</a>
|
<a class="btn" href="/">Dashboard</a>
|
||||||
<a class="btn" href="/history">History</a>
|
<a class="btn" href="/history">History</a>
|
||||||
<a class="btn primary" href="/alarms">Alarms</a>
|
<a class="btn primary" href="/alarms">Alarms</a>
|
||||||
<a class="btn" href="/kiosk">Kiosk</a>
|
<a class="btn" href="/kiosk">Kiosk</a>
|
||||||
<a class="btn" href="/process-capability">Process capability</a>
|
<a class="btn" href="/process-capability">Process capability</a>
|
||||||
<a class="btn" href="/reports">Reports</a>
|
<a class="btn" href="/reports">Reports</a>
|
||||||
<a class="btn" href="/license">License</a>
|
<a class="btn" href="/license">License</a>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
||||||
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass card" style="margin-bottom:18px;">
|
<div class="glass card" style="margin-bottom:18px;">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<div class="kicker">Force Monitor</div>
|
<div class="kicker">Force Monitor</div>
|
||||||
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">Alarm Timeline</h1>
|
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">Alarm Timeline</h1>
|
||||||
<div class="sub">Advanced event view with filters, summary cards, active-only mode, CSV export, and auto-refresh.</div>
|
<div class="sub">Advanced event view with filters, summary cards, active-only mode, CSV export, and auto-refresh.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="status-line">
|
<div class="status-line">
|
||||||
<span id="fetch-status">Status: idle</span>
|
<span id="fetch-status">Status: idle</span>
|
||||||
<span id="last-refresh">Last refresh: --</span>
|
<span id="last-refresh">Last refresh: --</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="license-warning" class="banner"></div>
|
<div id="license-warning" class="banner"></div>
|
||||||
|
|
||||||
<div class="grid cards">
|
<div class="grid cards">
|
||||||
<div class="glass card"><div class="kicker">Loaded events</div><div id="metric-total" class="value mono">0</div><div id="metric-total-sub" class="sub">Current filtered set</div></div>
|
<div class="glass card"><div class="kicker">Loaded events</div><div id="metric-total" class="value mono">0</div><div id="metric-total-sub" class="sub">Current filtered set</div></div>
|
||||||
<div class="glass card"><div class="kicker">Active alarms</div><div id="metric-active" class="value mono">0</div><div class="sub">State = active</div></div>
|
<div class="glass card"><div class="kicker">Active alarms</div><div id="metric-active" class="value mono">0</div><div class="sub">State = active</div></div>
|
||||||
<div class="glass card"><div class="kicker">Critical</div><div id="metric-critical" class="value mono">0</div><div class="sub">Severity critical</div></div>
|
<div class="glass card"><div class="kicker">Critical</div><div id="metric-critical" class="value mono">0</div><div class="sub">Severity critical</div></div>
|
||||||
<div class="glass card"><div class="kicker">Warning</div><div id="metric-warning" class="value mono">0</div><div class="sub">Severity warning</div></div>
|
<div class="glass card"><div class="kicker">Warning</div><div id="metric-warning" class="value mono">0</div><div class="sub">Severity warning</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass toolbar-wrap">
|
<div class="glass toolbar-wrap">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<select id="limit-select" title="Fetch limit">
|
<select id="limit-select" title="Fetch limit">
|
||||||
<option value="20">20 rows</option>
|
<option value="20">20 rows</option>
|
||||||
<option value="50" selected>50 rows</option>
|
<option value="50" selected>50 rows</option>
|
||||||
<option value="100">100 rows</option>
|
<option value="100">100 rows</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="severity-filter" title="Severity filter">
|
<select id="severity-filter" title="Severity filter">
|
||||||
<option value="all">All severities</option>
|
<option value="all">All severities</option>
|
||||||
<option value="critical">Critical</option>
|
<option value="critical">Critical</option>
|
||||||
<option value="warning">Warning</option>
|
<option value="warning">Warning</option>
|
||||||
<option value="info">Info</option>
|
<option value="info">Info</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="source-filter" title="Source filter">
|
<select id="source-filter" title="Source filter">
|
||||||
<option value="all">All sources</option>
|
<option value="all">All sources</option>
|
||||||
<option value="plc">PLC</option>
|
<option value="plc">PLC</option>
|
||||||
<option value="force_left">Left force</option>
|
<option value="force_left">Left force</option>
|
||||||
<option value="force_right">Right force</option>
|
<option value="force_right">Right force</option>
|
||||||
<option value="imbalance">Imbalance</option>
|
<option value="imbalance">Imbalance</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="state-filter" title="State filter">
|
<select id="state-filter" title="State filter">
|
||||||
<option value="all">All states</option>
|
<option value="all">All states</option>
|
||||||
<option value="active">Active only</option>
|
<option value="active">Active only</option>
|
||||||
<option value="clear">Clear only</option>
|
<option value="clear">Clear only</option>
|
||||||
<option value="info">Info only</option>
|
<option value="info">Info only</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="search-input" class="input" style="max-width:320px;" type="text" placeholder="Search source, message, time...">
|
<input id="search-input" class="input" style="max-width:320px;" type="text" placeholder="Search source, message, time...">
|
||||||
<label class="btn warn" style="gap:8px;"><input id="auto-refresh" type="checkbox" checked> Auto refresh</label>
|
<label class="btn warn" style="gap:8px;"><input id="auto-refresh" type="checkbox" checked> Auto refresh</label>
|
||||||
<button id="refresh-btn" class="btn primary" type="button">Refresh now</button>
|
<button id="refresh-btn" class="btn primary" type="button">Refresh now</button>
|
||||||
<button id="export-btn" class="btn good" type="button">Export CSV</button>
|
<button id="export-btn" class="btn good" type="button">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-line" style="margin-top:12px;">
|
<div class="status-line" style="margin-top:12px;">
|
||||||
<span>Tip: “active only” helps operators see what still matters right now.</span>
|
<span>Tip: “active only” helps operators see what still matters right now.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass table-wrap">
|
<div class="glass table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Source</th>
|
<th>Source</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Event</th>
|
<th>Event</th>
|
||||||
<th class="right">Value</th>
|
<th class="right">Value</th>
|
||||||
<th class="right">Limit</th>
|
<th class="right">Limit</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="alarm-body">
|
<tbody id="alarm-body">
|
||||||
<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">Loading alarms...</td></tr>
|
<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">Loading alarms...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app-common.js"></script>
|
<script src="/static/app-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let allEvents = [];
|
let allEvents = [];
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value === undefined || value === null ? '' : value)
|
return String(value === undefined || value === null ? '' : value)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
const t = theme === 'light' ? 'light' : 'dark';
|
const t = theme === 'light' ? 'light' : 'dark';
|
||||||
document.body.setAttribute('data-theme', t);
|
document.body.setAttribute('data-theme', t);
|
||||||
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
|
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
|
||||||
const btn = document.getElementById('theme-toggle');
|
const btn = document.getElementById('theme-toggle');
|
||||||
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
let theme = 'dark';
|
let theme = 'dark';
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('force-monitor-theme');
|
const stored = localStorage.getItem('force-monitor-theme');
|
||||||
if (stored === 'light' || stored === 'dark') theme = stored;
|
if (stored === 'light' || stored === 'dark') theme = stored;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPct(value) {
|
function formatPct(value) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? n.toFixed(1) + '%' : '--';
|
return Number.isFinite(n) ? n.toFixed(1) + '%' : '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSource(source) {
|
function formatSource(source) {
|
||||||
return ({ force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' })[source] || String(source || '').toUpperCase();
|
return ({ force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' })[source] || String(source || '').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function severityPill(severity, state) {
|
function severityPill(severity, state) {
|
||||||
const sev = String(severity || 'info').toLowerCase();
|
const sev = String(severity || 'info').toLowerCase();
|
||||||
const klass = state === 'clear' ? 'clear' : sev;
|
const klass = state === 'clear' ? 'clear' : sev;
|
||||||
const label = state === 'clear' ? 'CLEAR' : sev.toUpperCase();
|
const label = state === 'clear' ? 'CLEAR' : sev.toUpperCase();
|
||||||
return '<span class="pill ' + escapeHtml(klass) + '">' + escapeHtml(label) + '</span>';
|
return '<span class="pill ' + escapeHtml(klass) + '">' + escapeHtml(label) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMetrics(events) {
|
function setMetrics(events) {
|
||||||
const active = events.filter(e => String(e.state || '').toLowerCase() === 'active').length;
|
const active = events.filter(e => String(e.state || '').toLowerCase() === 'active').length;
|
||||||
const critical = events.filter(e => String(e.severity || '').toLowerCase() === 'critical').length;
|
const critical = events.filter(e => String(e.severity || '').toLowerCase() === 'critical').length;
|
||||||
const warning = events.filter(e => String(e.severity || '').toLowerCase() === 'warning').length;
|
const warning = events.filter(e => String(e.severity || '').toLowerCase() === 'warning').length;
|
||||||
document.getElementById('metric-total').textContent = String(events.length);
|
document.getElementById('metric-total').textContent = String(events.length);
|
||||||
document.getElementById('metric-active').textContent = String(active);
|
document.getElementById('metric-active').textContent = String(active);
|
||||||
document.getElementById('metric-critical').textContent = String(critical);
|
document.getElementById('metric-critical').textContent = String(critical);
|
||||||
document.getElementById('metric-warning').textContent = String(warning);
|
document.getElementById('metric-warning').textContent = String(warning);
|
||||||
document.getElementById('metric-total-sub').textContent = events.length === allEvents.length ? 'Current fetched set' : 'Filtered view';
|
document.getElementById('metric-total-sub').textContent = events.length === allEvents.length ? 'Current fetched set' : 'Filtered view';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(events) {
|
function renderTable(events) {
|
||||||
const body = document.getElementById('alarm-body');
|
const body = document.getElementById('alarm-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
body.innerHTML = '<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">No events match the current filters</td></tr>';
|
body.innerHTML = '<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">No events match the current filters</td></tr>';
|
||||||
setMetrics(events);
|
setMetrics(events);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
const state = String(ev.state || '').toLowerCase();
|
const state = String(ev.state || '').toLowerCase();
|
||||||
const value = ev.source === 'plc' ? '--' : formatPct(ev.value);
|
const value = ev.source === 'plc' ? '--' : formatPct(ev.value);
|
||||||
const limit = Number(ev.limit) > 0 ? formatPct(ev.limit) : '--';
|
const limit = Number(ev.limit) > 0 ? formatPct(ev.limit) : '--';
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td class="mono">' + escapeHtml(ev.time || '--') + '</td>' +
|
'<td class="mono">' + escapeHtml(ev.time || '--') + '</td>' +
|
||||||
'<td>' + severityPill(ev.severity, state) + '</td>' +
|
'<td>' + severityPill(ev.severity, state) + '</td>' +
|
||||||
'<td style="font-weight:700;">' + escapeHtml(formatSource(ev.source)) + '</td>' +
|
'<td style="font-weight:700;">' + escapeHtml(formatSource(ev.source)) + '</td>' +
|
||||||
'<td class="mono">' + escapeHtml(state || '--') + '</td>' +
|
'<td class="mono">' + escapeHtml(state || '--') + '</td>' +
|
||||||
'<td>' + escapeHtml(ev.message || '--') + '</td>' +
|
'<td>' + escapeHtml(ev.message || '--') + '</td>' +
|
||||||
'<td class="right mono">' + escapeHtml(value) + '</td>' +
|
'<td class="right mono">' + escapeHtml(value) + '</td>' +
|
||||||
'<td class="right mono">' + escapeHtml(limit) + '</td>' +
|
'<td class="right mono">' + escapeHtml(limit) + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
setMetrics(events);
|
setMetrics(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilters() {
|
function getFilters() {
|
||||||
return {
|
return {
|
||||||
severity: document.getElementById('severity-filter').value,
|
severity: document.getElementById('severity-filter').value,
|
||||||
source: document.getElementById('source-filter').value,
|
source: document.getElementById('source-filter').value,
|
||||||
state: document.getElementById('state-filter').value,
|
state: document.getElementById('state-filter').value,
|
||||||
search: document.getElementById('search-input').value.trim().toLowerCase()
|
search: document.getElementById('search-input').value.trim().toLowerCase()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const f = getFilters();
|
const f = getFilters();
|
||||||
const out = allEvents.filter(ev => {
|
const out = allEvents.filter(ev => {
|
||||||
const sev = String(ev.severity || '').toLowerCase();
|
const sev = String(ev.severity || '').toLowerCase();
|
||||||
const src = String(ev.source || '').toLowerCase();
|
const src = String(ev.source || '').toLowerCase();
|
||||||
const state = String(ev.state || '').toLowerCase();
|
const state = String(ev.state || '').toLowerCase();
|
||||||
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
|
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
|
||||||
if (f.severity !== 'all' && sev !== f.severity) return false;
|
if (f.severity !== 'all' && sev !== f.severity) return false;
|
||||||
if (f.source !== 'all' && src !== f.source) return false;
|
if (f.source !== 'all' && src !== f.source) return false;
|
||||||
if (f.state !== 'all' && state !== f.state) return false;
|
if (f.state !== 'all' && state !== f.state) return false;
|
||||||
if (f.search && !hay.includes(f.search)) return false;
|
if (f.search && !hay.includes(f.search)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
renderTable(out);
|
renderTable(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBanner(msg, show) {
|
function updateBanner(msg, show) {
|
||||||
const el = document.getElementById('license-warning');
|
const el = document.getElementById('license-warning');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = msg || '';
|
el.textContent = msg || '';
|
||||||
el.classList.toggle('show', !!show);
|
el.classList.toggle('show', !!show);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAlarms() {
|
async function fetchAlarms() {
|
||||||
const limit = document.getElementById('limit-select').value || '50';
|
const limit = document.getElementById('limit-select').value || '50';
|
||||||
document.getElementById('fetch-status').textContent = 'Status: loading...';
|
document.getElementById('fetch-status').textContent = 'Status: loading...';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/alarms?limit=' + encodeURIComponent(limit), { cache: 'no-store' });
|
const res = await fetch('/api/alarms?limit=' + encodeURIComponent(limit), { cache: 'no-store' });
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
const message = data && data.error ? data.error : 'license required';
|
const message = data && data.error ? data.error : 'license required';
|
||||||
allEvents = [];
|
allEvents = [];
|
||||||
applyFilters();
|
applyFilters();
|
||||||
updateBanner('Alarm API is locked: ' + message + '. Open /license to activate the app.', true);
|
updateBanner('Alarm API is locked: ' + message + '. Open /license to activate the app.', true);
|
||||||
document.getElementById('fetch-status').textContent = 'Status: license locked';
|
document.getElementById('fetch-status').textContent = 'Status: license locked';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
allEvents = Array.isArray(data.events) ? data.events : [];
|
allEvents = Array.isArray(data.events) ? data.events : [];
|
||||||
applyFilters();
|
applyFilters();
|
||||||
updateBanner('', false);
|
updateBanner('', false);
|
||||||
document.getElementById('fetch-status').textContent = 'Status: OK';
|
document.getElementById('fetch-status').textContent = 'Status: OK';
|
||||||
document.getElementById('last-refresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
|
document.getElementById('last-refresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Alarm fetch error:', err);
|
console.warn('Alarm fetch error:', err);
|
||||||
allEvents = [];
|
allEvents = [];
|
||||||
applyFilters();
|
applyFilters();
|
||||||
updateBanner('Could not load alarms. Check app connectivity and browser console.', true);
|
updateBanner('Could not load alarms. Check app connectivity and browser console.', true);
|
||||||
document.getElementById('fetch-status').textContent = 'Status: error';
|
document.getElementById('fetch-status').textContent = 'Status: error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
const rows = [['time','severity','source','state','message','value','limit']];
|
const rows = [['time','severity','source','state','message','value','limit']];
|
||||||
const events = allEvents.filter(ev => {
|
const events = allEvents.filter(ev => {
|
||||||
const f = getFilters();
|
const f = getFilters();
|
||||||
const sev = String(ev.severity || '').toLowerCase();
|
const sev = String(ev.severity || '').toLowerCase();
|
||||||
const src = String(ev.source || '').toLowerCase();
|
const src = String(ev.source || '').toLowerCase();
|
||||||
const state = String(ev.state || '').toLowerCase();
|
const state = String(ev.state || '').toLowerCase();
|
||||||
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
|
const hay = [ev.time, ev.source, ev.state, ev.message, ev.severity].join(' ').toLowerCase();
|
||||||
if (f.severity !== 'all' && sev !== f.severity) return false;
|
if (f.severity !== 'all' && sev !== f.severity) return false;
|
||||||
if (f.source !== 'all' && src !== f.source) return false;
|
if (f.source !== 'all' && src !== f.source) return false;
|
||||||
if (f.state !== 'all' && state !== f.state) return false;
|
if (f.state !== 'all' && state !== f.state) return false;
|
||||||
if (f.search && !hay.includes(f.search)) return false;
|
if (f.search && !hay.includes(f.search)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
rows.push([ev.time || '', ev.severity || '', ev.source || '', ev.state || '', ev.message || '', ev.value ?? '', ev.limit ?? '']);
|
rows.push([ev.time || '', ev.severity || '', ev.source || '', ev.state || '', ev.message || '', ev.value ?? '', ev.limit ?? '']);
|
||||||
}
|
}
|
||||||
const csv = rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\r\n');
|
const csv = rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\r\n');
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'force-monitor-alarms-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
|
a.download = 'force-monitor-alarms-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncAutoRefresh() {
|
function syncAutoRefresh() {
|
||||||
const enabled = document.getElementById('auto-refresh').checked;
|
const enabled = document.getElementById('auto-refresh').checked;
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
if (enabled) refreshTimer = setInterval(fetchAlarms, 3000);
|
if (enabled) refreshTimer = setInterval(fetchAlarms, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
AppUI.initTheme();
|
AppUI.initTheme();
|
||||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||||
document.getElementById('refresh-btn').addEventListener('click', fetchAlarms);
|
document.getElementById('refresh-btn').addEventListener('click', fetchAlarms);
|
||||||
document.getElementById('export-btn').addEventListener('click', exportCSV);
|
document.getElementById('export-btn').addEventListener('click', exportCSV);
|
||||||
document.getElementById('auto-refresh').addEventListener('change', syncAutoRefresh);
|
document.getElementById('auto-refresh').addEventListener('change', syncAutoRefresh);
|
||||||
['severity-filter','source-filter','state-filter','search-input','limit-select'].forEach(id => {
|
['severity-filter','source-filter','state-filter','search-input','limit-select'].forEach(id => {
|
||||||
document.getElementById(id).addEventListener(id === 'search-input' ? 'input' : 'change', () => {
|
document.getElementById(id).addEventListener(id === 'search-input' ? 'input' : 'change', () => {
|
||||||
if (id === 'limit-select') fetchAlarms(); else applyFilters();
|
if (id === 'limit-select') fetchAlarms(); else applyFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
fetchAlarms();
|
fetchAlarms();
|
||||||
syncAutoRefresh();
|
syncAutoRefresh();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,176 +1,176 @@
|
||||||
(function(){
|
(function(){
|
||||||
const THEME_KEY = 'force-monitor-theme';
|
const THEME_KEY = 'force-monitor-theme';
|
||||||
const FULLSCREEN_INTENT_KEY = 'force-monitor-fullscreen-intent';
|
const FULLSCREEN_INTENT_KEY = 'force-monitor-fullscreen-intent';
|
||||||
|
|
||||||
function byId(id){ return id ? document.getElementById(id) : null; }
|
function byId(id){ return id ? document.getElementById(id) : null; }
|
||||||
|
|
||||||
function getFullscreenIntent(){
|
function getFullscreenIntent(){
|
||||||
try { return sessionStorage.getItem(FULLSCREEN_INTENT_KEY) === '1'; } catch (_) { return false; }
|
try { return sessionStorage.getItem(FULLSCREEN_INTENT_KEY) === '1'; } catch (_) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFullscreenIntent(enabled){
|
function setFullscreenIntent(enabled){
|
||||||
try { sessionStorage.setItem(FULLSCREEN_INTENT_KEY, enabled ? '1' : '0'); } catch (_) {}
|
try { sessionStorage.setItem(FULLSCREEN_INTENT_KEY, enabled ? '1' : '0'); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme, opts){
|
function setTheme(theme, opts){
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const t = theme === 'light' ? 'light' : 'dark';
|
const t = theme === 'light' ? 'light' : 'dark';
|
||||||
document.body.setAttribute('data-theme', t);
|
document.body.setAttribute('data-theme', t);
|
||||||
try { localStorage.setItem(THEME_KEY, t); } catch (_) {}
|
try { localStorage.setItem(THEME_KEY, t); } catch (_) {}
|
||||||
const btn = byId(opts.buttonId || 'theme-toggle');
|
const btn = byId(opts.buttonId || 'theme-toggle');
|
||||||
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
||||||
if (typeof opts.onChange === 'function') opts.onChange(t);
|
if (typeof opts.onChange === 'function') opts.onChange(t);
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme(opts){
|
function initTheme(opts){
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
let theme = 'dark';
|
let theme = 'dark';
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(THEME_KEY);
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
if (stored === 'light' || stored === 'dark') theme = stored;
|
if (stored === 'light' || stored === 'dark') theme = stored;
|
||||||
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) theme = 'light';
|
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) theme = 'light';
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
setTheme(theme, opts);
|
setTheme(theme, opts);
|
||||||
const btn = byId(opts.buttonId || 'theme-toggle');
|
const btn = byId(opts.buttonId || 'theme-toggle');
|
||||||
if (btn && !btn.dataset.themeBound) {
|
if (btn && !btn.dataset.themeBound) {
|
||||||
btn.dataset.themeBound = '1';
|
btn.dataset.themeBound = '1';
|
||||||
btn.addEventListener('click', function(){
|
btn.addEventListener('click', function(){
|
||||||
setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light', opts);
|
setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light', opts);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFullscreenButton(buttonId){
|
function updateFullscreenButton(buttonId){
|
||||||
const btn = byId(buttonId || 'fullscreen-toggle');
|
const btn = byId(buttonId || 'fullscreen-toggle');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
btn.textContent = 'Exit fullscreen';
|
btn.textContent = 'Exit fullscreen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btn.textContent = getFullscreenIntent() ? 'Restore fullscreen' : 'Enter fullscreen';
|
btn.textContent = getFullscreenIntent() ? 'Restore fullscreen' : 'Enter fullscreen';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestFullscreenSafe(){
|
async function requestFullscreenSafe(){
|
||||||
if (document.fullscreenElement) return true;
|
if (document.fullscreenElement) return true;
|
||||||
if (!document.fullscreenEnabled) return false;
|
if (!document.fullscreenEnabled) return false;
|
||||||
try {
|
try {
|
||||||
await document.documentElement.requestFullscreen();
|
await document.documentElement.requestFullscreen();
|
||||||
setFullscreenIntent(true);
|
setFullscreenIntent(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Fullscreen restore/request blocked:', err);
|
console.warn('Fullscreen restore/request blocked:', err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFullscreen(buttonId){
|
async function toggleFullscreen(buttonId){
|
||||||
try {
|
try {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
await requestFullscreenSafe();
|
await requestFullscreenSafe();
|
||||||
} else {
|
} else {
|
||||||
setFullscreenIntent(false);
|
setFullscreenIntent(false);
|
||||||
await document.exitFullscreen();
|
await document.exitFullscreen();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Fullscreen error:', err);
|
console.warn('Fullscreen error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
updateFullscreenButton(buttonId || 'fullscreen-toggle');
|
updateFullscreenButton(buttonId || 'fullscreen-toggle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindFullscreenNavPersistence(){
|
function bindFullscreenNavPersistence(){
|
||||||
if (document.documentElement.dataset.fsNavBound) return;
|
if (document.documentElement.dataset.fsNavBound) return;
|
||||||
document.documentElement.dataset.fsNavBound = '1';
|
document.documentElement.dataset.fsNavBound = '1';
|
||||||
|
|
||||||
document.addEventListener('click', function(ev){
|
document.addEventListener('click', function(ev){
|
||||||
const link = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
const link = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
const href = link.getAttribute('href') || '';
|
const href = link.getAttribute('href') || '';
|
||||||
const target = link.getAttribute('target') || '';
|
const target = link.getAttribute('target') || '';
|
||||||
if (!href || href.startsWith('#') || target === '_blank' || link.hasAttribute('download')) return;
|
if (!href || href.startsWith('#') || target === '_blank' || link.hasAttribute('download')) return;
|
||||||
try {
|
try {
|
||||||
const url = new URL(link.href, window.location.href);
|
const url = new URL(link.href, window.location.href);
|
||||||
if (url.origin !== window.location.origin) return;
|
if (url.origin !== window.location.origin) return;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (document.fullscreenElement || getFullscreenIntent()) {
|
if (document.fullscreenElement || getFullscreenIntent()) {
|
||||||
setFullscreenIntent(true);
|
setFullscreenIntent(true);
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('pageshow', function(){
|
window.addEventListener('pageshow', function(){
|
||||||
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
|
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
|
||||||
updateFullscreenButton(el.id);
|
updateFullscreenButton(el.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initFullscreen(opts){
|
async function initFullscreen(opts){
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const buttonId = opts.buttonId || 'fullscreen-toggle';
|
const buttonId = opts.buttonId || 'fullscreen-toggle';
|
||||||
const btn = byId(buttonId);
|
const btn = byId(buttonId);
|
||||||
if (btn && !btn.dataset.fsBound) {
|
if (btn && !btn.dataset.fsBound) {
|
||||||
btn.dataset.fsBound = '1';
|
btn.dataset.fsBound = '1';
|
||||||
btn.addEventListener('click', function(){ toggleFullscreen(buttonId); });
|
btn.addEventListener('click', function(){ toggleFullscreen(buttonId); });
|
||||||
}
|
}
|
||||||
if (!document.documentElement.dataset.fsListenerBound) {
|
if (!document.documentElement.dataset.fsListenerBound) {
|
||||||
document.documentElement.dataset.fsListenerBound = '1';
|
document.documentElement.dataset.fsListenerBound = '1';
|
||||||
document.addEventListener('fullscreenchange', function(){
|
document.addEventListener('fullscreenchange', function(){
|
||||||
setFullscreenIntent(!!document.fullscreenElement);
|
setFullscreenIntent(!!document.fullscreenElement);
|
||||||
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
|
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
|
||||||
updateFullscreenButton(el.id);
|
updateFullscreenButton(el.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
bindFullscreenNavPersistence();
|
bindFullscreenNavPersistence();
|
||||||
updateFullscreenButton(buttonId);
|
updateFullscreenButton(buttonId);
|
||||||
|
|
||||||
if (getFullscreenIntent() && !document.fullscreenElement) {
|
if (getFullscreenIntent() && !document.fullscreenElement) {
|
||||||
// Best effort only: some browsers require a fresh user gesture after navigation.
|
// Best effort only: some browsers require a fresh user gesture after navigation.
|
||||||
requestAnimationFrame(function(){
|
requestAnimationFrame(function(){
|
||||||
requestFullscreenSafe().finally(function(){ updateFullscreenButton(buttonId); });
|
requestFullscreenSafe().finally(function(){ updateFullscreenButton(buttonId); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, opts){
|
async function fetchJson(url, opts){
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutMs = opts.timeoutMs || 8000;
|
const timeoutMs = opts.timeoutMs || 8000;
|
||||||
const timer = setTimeout(function(){ controller.abort(); }, timeoutMs);
|
const timer = setTimeout(function(){ controller.abort(); }, timeoutMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: opts.method || 'GET',
|
method: opts.method || 'GET',
|
||||||
headers: opts.headers || undefined,
|
headers: opts.headers || undefined,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
let data = null;
|
let data = null;
|
||||||
try { data = await res.json(); } catch (_) { data = null; }
|
try { data = await res.json(); } catch (_) { data = null; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data && data.error ? data.error : ('HTTP ' + res.status));
|
const err = new Error(data && data.error ? data.error : ('HTTP ' + res.status));
|
||||||
err.response = res;
|
err.response = res;
|
||||||
err.data = data;
|
err.data = data;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.AppUI = {
|
window.AppUI = {
|
||||||
setTheme,
|
setTheme,
|
||||||
initTheme,
|
initTheme,
|
||||||
updateFullscreenButton,
|
updateFullscreenButton,
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
initFullscreen,
|
initFullscreen,
|
||||||
fetchJson,
|
fetchJson,
|
||||||
getFullscreenIntent,
|
getFullscreenIntent,
|
||||||
setFullscreenIntent
|
setFullscreenIntent
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
1092
static/history.html
1092
static/history.html
File diff suppressed because it is too large
Load diff
2868
static/index.html
2868
static/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1,103 +1,104 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<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>
|
||||||
: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;}
|
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
||||||
*{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));}
|
: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;}
|
||||||
.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}
|
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;}
|
||||||
.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}
|
*{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));}
|
||||||
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
|
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));}
|
||||||
.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}
|
.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}
|
||||||
.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}
|
.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}
|
||||||
.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}
|
body[data-theme="light"] .btn{background:rgba(255,255,255,.88);}
|
||||||
.ok{color:var(--ok)} .warning{color:var(--warn)} .critical{color:var(--bad)} .neutral{color:var(--muted)}
|
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
|
||||||
.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)}
|
.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}
|
||||||
@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}}
|
.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}
|
||||||
@media (max-width:760px){.cards{grid-template-columns:1fr}.wrap{padding:14px}.value{font-size:36px}.status{font-size:38px}}
|
.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}
|
||||||
</style>
|
.ok{color:var(--ok)} .warning{color:var(--warn)} .critical{color:var(--bad)} .neutral{color:var(--muted)}
|
||||||
</head>
|
.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)}
|
||||||
<body>
|
@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}}
|
||||||
<div class="wrap">
|
@media (max-width:760px){.cards{grid-template-columns:1fr}.wrap{padding:14px}.value{font-size:36px}.status{font-size:38px}}
|
||||||
<div class="nav" style="margin-bottom:14px">
|
</style>
|
||||||
<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>
|
</head>
|
||||||
<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>
|
<body>
|
||||||
</div>
|
<div class="wrap">
|
||||||
<div id="alarm-banner" class="banner"></div>
|
<div class="nav" style="margin-bottom:14px">
|
||||||
<div class="glass hero">
|
<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>
|
||||||
<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 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 class="grid cards">
|
<div id="alarm-banner" class="banner"></div>
|
||||||
<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 hero">
|
||||||
<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="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 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>
|
||||||
<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 class="grid cards">
|
||||||
</div>
|
<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="split">
|
<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 panel">
|
<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="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 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 id="verdict-summary" class="status neutral" style="font-size:56px;margin-top:14px">WAITING</div>
|
</div>
|
||||||
<div id="verdict-detail" class="sub" style="font-size:22px;margin-top:12px">No PLC data yet.</div>
|
<div class="split">
|
||||||
<div class="row" style="margin-top:22px">
|
<div class="glass panel">
|
||||||
<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="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 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 id="verdict-summary" class="status neutral" style="font-size:56px;margin-top:14px">WAITING</div>
|
||||||
</div>
|
<div id="verdict-detail" class="sub" style="font-size:22px;margin-top:12px">No PLC data yet.</div>
|
||||||
</div>
|
<div class="row" style="margin-top:22px">
|
||||||
<div class="glass panel">
|
<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="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>
|
<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>
|
||||||
<ul id="attention-list"><li>Loading live status…</li></ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="glass panel">
|
||||||
</div>
|
<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>
|
||||||
<script src="/static/app-common.js"></script>
|
<ul id="attention-list"><li>Loading live status…</li></ul>
|
||||||
<script>
|
</div>
|
||||||
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}};
|
</div>
|
||||||
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';}
|
</div>
|
||||||
function initTheme(){let t='dark'; try{t=localStorage.getItem('force-monitor-theme')||'dark'}catch(e){} setTheme(t==='light'?'light':'dark');}
|
<script src="/static/app-common.js"></script>
|
||||||
function updateFullscreenButton(){const btn=document.getElementById('fullscreen-btn'); if(btn) btn.textContent=document.fullscreenElement?'Exit fullscreen':'Enter fullscreen';}
|
<script>
|
||||||
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok';
|
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 zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
|
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok';
|
||||||
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
|
function zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
|
||||||
async function loadCfg(){try{const r=await fetch('/api/config/public',{cache:'no-store'}); if(r.ok){cfg=await r.json(); setThemeTitle();}}catch(e){}}
|
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
|
||||||
async function refreshAll(){
|
async function loadCfg(){try{const r=await fetch('/api/config/public',{cache:'no-store'}); if(r.ok){cfg=await r.json(); setThemeTitle();}}catch(e){}}
|
||||||
try{
|
async function refreshAll(){
|
||||||
const [dataRes, trendRes, alarmsRes]=await Promise.all([
|
try{
|
||||||
fetch('/api/data',{cache:'no-store'}), fetch('/api/trend?window=15m',{cache:'no-store'}), fetch('/api/alarms?limit=8',{cache:'no-store'})
|
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:[]};
|
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 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 d=await dataRes.json(); const t=trendRes.ok?await trendRes.json():{}; const a=alarmsRes.ok?await alarmsRes.json():{events:[]};
|
||||||
const lkn=Number(d.sila_l_kn)||0, rkn=Number(d.sila_r_kn)||0;
|
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;
|
||||||
document.getElementById('clock').textContent=new Date().toLocaleString(); document.getElementById('last-refresh').textContent='Last refresh: '+new Date().toLocaleTimeString();
|
const lkn=Number(d.sila_l_kn)||0, rkn=Number(d.sila_r_kn)||0;
|
||||||
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('clock').textContent=new Date().toLocaleString(); document.getElementById('last-refresh').textContent='Last refresh: '+new Date().toLocaleTimeString();
|
||||||
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('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('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('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('imb-value').textContent=fmt(imb,1)+(cfg.ui.unit_percent||'%'); document.getElementById('imb-sub').textContent='Bias '+fmt(bias,1)+(cfg.ui.unit_percent||'%');
|
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');
|
||||||
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);
|
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 statusEl=document.getElementById('status-text'); const reasonEl=document.getElementById('status-reason'); const verdict=document.getElementById('verdict-summary'); const detail=document.getElementById('verdict-detail');
|
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);
|
||||||
let verdictText='OK', reason='Production looks stable.'; let level='ok';
|
const statusEl=document.getElementById('status-text'); const reasonEl=document.getElementById('status-reason'); const verdict=document.getElementById('verdict-summary'); const detail=document.getElementById('verdict-detail');
|
||||||
if(!connected){ verdictText='PLC OFFLINE'; reason='No PLC communication.'; level='critical'; }
|
let verdictText='OK', reason='Production looks stable.'; let level='ok';
|
||||||
else if(stale){ verdictText='STALE DATA'; reason='PLC connected, but no fresh values are arriving.'; level='warning'; }
|
if(!connected){ verdictText='PLC OFFLINE'; reason='No PLC communication.'; level='critical'; }
|
||||||
else if(zForce==='critical' || zImb==='critical'){ verdictText='CRITICAL'; reason='Force or imbalance reached critical region.'; level='critical'; }
|
else if(stale){ verdictText='STALE DATA'; reason='PLC connected, but no fresh values are arriving.'; level='warning'; }
|
||||||
else if(zForce==='warning' || zImb==='warning'){ verdictText='WARNING'; reason='Process is above warning thresholds.'; level='warning'; }
|
else if(zForce==='critical' || zImb==='critical'){ verdictText='CRITICAL'; reason='Force or imbalance reached critical region.'; level='critical'; }
|
||||||
statusEl.textContent=verdictText; statusEl.className='status '+level; reasonEl.textContent=reason; verdict.textContent=verdictText; verdict.className='status '+level; detail.textContent=reason;
|
else if(zForce==='warning' || zImb==='warning'){ verdictText='WARNING'; reason='Process is above warning thresholds.'; level='warning'; }
|
||||||
document.getElementById('stale-pill').textContent='Data freshness: '+(stale?'stale':connected?'fresh':'offline');
|
statusEl.textContent=verdictText; statusEl.className='status '+level; reasonEl.textContent=reason; verdict.textContent=verdictText; verdict.className='status '+level; detail.textContent=reason;
|
||||||
document.getElementById('trend-force').textContent=((Number(t.force_delta_pct)||0)>=0?'+':'')+fmt(t.force_delta_pct,1)+(cfg.ui.unit_percent||'%');
|
document.getElementById('stale-pill').textContent='Data freshness: '+(stale?'stale':connected?'fresh':'offline');
|
||||||
document.getElementById('trend-force').className='value mono '+(((Number(t.force_delta_pct)||0)>=3)?'warning':'ok');
|
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-sub').textContent=(t.force_direction||'--')+' over 15m';
|
document.getElementById('trend-force').className='value mono '+(((Number(t.force_delta_pct)||0)>=3)?'warning':'ok');
|
||||||
document.getElementById('trend-stability').textContent=String(t.process_stability||'--').toUpperCase();
|
document.getElementById('trend-force-sub').textContent=(t.force_direction||'--')+' over 15m';
|
||||||
document.getElementById('trend-stability').className='value mono '+(t.process_stability==='unstable'?'critical':t.process_stability==='caution'?'warning':'ok');
|
document.getElementById('trend-stability').textContent=String(t.process_stability||'--').toUpperCase();
|
||||||
document.getElementById('trend-stability-sub').textContent=t.stability_reason||'No trend reason';
|
document.getElementById('trend-stability').className='value mono '+(t.process_stability==='unstable'?'critical':t.process_stability==='caution'?'warning':'ok');
|
||||||
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.');
|
document.getElementById('trend-stability-sub').textContent=t.stability_reason||'No trend reason';
|
||||||
(a.events||[]).slice(0,4).forEach(ev=>{if(ev.severity!=='info') attention.push((ev.time||'')+' • '+(ev.message||''));});
|
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.');
|
||||||
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);});
|
(a.events||[]).slice(0,4).forEach(ev=>{if(ev.severity!=='info') attention.push((ev.time||'')+' • '+(ev.message||''));});
|
||||||
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');}
|
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);});
|
||||||
}catch(err){console.warn(err)}
|
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);
|
AppUI.initTheme(); AppUI.initFullscreen({ buttonId:'fullscreen-btn' });
|
||||||
</script>
|
loadCfg().then(refreshAll); setInterval(refreshAll, 1500);
|
||||||
</body>
|
</script>
|
||||||
</html>
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,336 +1,336 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<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 — License</title>
|
<title>Force Monitor — License</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg1:#050816; --bg2:#0b1224; --panel:rgba(255,255,255,0.055); --border:rgba(255,255,255,0.10);
|
--bg1:#050816; --bg2:#0b1224; --panel:rgba(255,255,255,0.055); --border:rgba(255,255,255,0.10);
|
||||||
--text:#f4f4f5; --muted:#a1a1aa; --muted2:#71717a; --btnbg:rgba(255,255,255,0.05); --shadow:0 16px 36px rgba(0,0,0,0.18);
|
--text:#f4f4f5; --muted:#a1a1aa; --muted2:#71717a; --btnbg:rgba(255,255,255,0.05); --shadow:0 16px 36px rgba(0,0,0,0.18);
|
||||||
--ok:#34d399; --warn:#facc15; --bad:#f87171; --info:#93c5fd;
|
--ok:#34d399; --warn:#facc15; --bad:#f87171; --info:#93c5fd;
|
||||||
}
|
}
|
||||||
body[data-theme="light"] {
|
body[data-theme="light"] {
|
||||||
--bg1:#eef4ff; --bg2:#f8fafc; --panel:rgba(255,255,255,0.84); --border:rgba(15,23,42,0.10);
|
--bg1:#eef4ff; --bg2:#f8fafc; --panel:rgba(255,255,255,0.84); --border:rgba(15,23,42,0.10);
|
||||||
--text:#0f172a; --muted:#475569; --muted2:#64748b; --btnbg:rgba(255,255,255,0.88); --shadow:0 16px 36px rgba(15,23,42,0.08);
|
--text:#0f172a; --muted:#475569; --muted2:#64748b; --btnbg:rgba(255,255,255,0.88); --shadow:0 16px 36px rgba(15,23,42,0.08);
|
||||||
--ok:#059669; --warn:#b45309; --bad:#dc2626; --info:#1d4ed8;
|
--ok:#059669; --warn:#b45309; --bad:#dc2626; --info:#1d4ed8;
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body {
|
body {
|
||||||
margin:0; min-height:100vh; color:var(--text); font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
|
margin:0; min-height:100vh; color:var(--text); font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
||||||
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
||||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||||||
}
|
}
|
||||||
body[data-theme="light"] {
|
body[data-theme="light"] {
|
||||||
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%),
|
||||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
||||||
}
|
}
|
||||||
.wrap { width:min(94vw, 1560px); margin:0 auto; padding:24px; }
|
.wrap { width:min(94vw, 1560px); 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); box-shadow:var(--shadow); }
|
.glass { background:var(--panel); border:1px solid var(--border); border-radius:24px; backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); box-shadow:var(--shadow); }
|
||||||
.nav, .row, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
.nav, .row, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
||||||
.nav { margin-bottom:18px; }
|
.nav { margin-bottom:18px; }
|
||||||
.btn, .input, textarea {
|
.btn, .input, textarea {
|
||||||
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
|
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
|
||||||
padding:10px 14px; font:inherit;
|
padding:10px 14px; font:inherit;
|
||||||
}
|
}
|
||||||
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
|
||||||
.btn.good { background:rgba(16,185,129,0.14); border-color:rgba(16,185,129,0.35); }
|
.btn.good { background:rgba(16,185,129,0.14); border-color:rgba(16,185,129,0.35); }
|
||||||
.btn.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
|
.btn.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
|
||||||
.grid { display:grid; gap:16px; }
|
.grid { display:grid; gap:16px; }
|
||||||
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
|
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
|
||||||
.card { padding:18px 20px; }
|
.card { padding:18px 20px; }
|
||||||
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
|
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
|
||||||
.value { font-size:30px; font-weight:800; margin-top:8px; }
|
.value { font-size:30px; font-weight:800; margin-top:8px; }
|
||||||
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
|
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
|
||||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
||||||
textarea { width:100%; min-height:210px; resize:vertical; }
|
textarea { width:100%; min-height:210px; resize:vertical; }
|
||||||
.two { grid-template-columns:1.1fr .9fr; }
|
.two { grid-template-columns:1.1fr .9fr; }
|
||||||
pre {
|
pre {
|
||||||
margin:0; padding:16px; border-radius:18px; border:1px solid var(--border); background:rgba(2,6,23,0.35);
|
margin:0; padding:16px; border-radius:18px; border:1px solid var(--border); background:rgba(2,6,23,0.35);
|
||||||
color:var(--text); white-space:pre-wrap; word-break:break-word; min-height:210px; overflow:auto;
|
color:var(--text); white-space:pre-wrap; word-break:break-word; min-height:210px; overflow:auto;
|
||||||
}
|
}
|
||||||
body[data-theme="light"] pre { background:rgba(248,250,252,0.96); }
|
body[data-theme="light"] pre { background:rgba(248,250,252,0.96); }
|
||||||
.badge { display:inline-flex; align-items:center; gap:8px; border-radius:999px; padding:6px 12px; font-weight:700; border:1px solid var(--border); }
|
.badge { display:inline-flex; align-items:center; gap:8px; border-radius:999px; padding:6px 12px; font-weight:700; border:1px solid var(--border); }
|
||||||
.ok { color:var(--ok); }
|
.ok { color:var(--ok); }
|
||||||
.warnc { color:var(--warn); }
|
.warnc { color:var(--warn); }
|
||||||
.bad { color:var(--bad); }
|
.bad { color:var(--bad); }
|
||||||
.info { color:var(--info); }
|
.info { color:var(--info); }
|
||||||
.field { margin-top:12px; }
|
.field { margin-top:12px; }
|
||||||
.label { color:var(--muted2); font-size:12px; letter-spacing:.14em; text-transform:uppercase; margin-bottom:6px; }
|
.label { color:var(--muted2); font-size:12px; letter-spacing:.14em; text-transform:uppercase; margin-bottom:6px; }
|
||||||
.details { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
|
.details { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
|
||||||
.detail-box { padding:14px 16px; border-radius:18px; border:1px solid var(--border); background:rgba(255,255,255,0.03); }
|
.detail-box { padding:14px 16px; border-radius:18px; border:1px solid var(--border); background:rgba(255,255,255,0.03); }
|
||||||
body[data-theme="light"] .detail-box { background:rgba(255,255,255,0.68); }
|
body[data-theme="light"] .detail-box { background:rgba(255,255,255,0.68); }
|
||||||
.message { margin-top:12px; padding:14px 16px; border-radius:16px; display:none; }
|
.message { margin-top:12px; padding:14px 16px; border-radius:16px; display:none; }
|
||||||
.message.show { display:block; }
|
.message.show { display:block; }
|
||||||
.message.good { background:rgba(16,185,129,0.12); border:1px solid rgba(16,185,129,0.24); color:#bbf7d0; }
|
.message.good { background:rgba(16,185,129,0.12); border:1px solid rgba(16,185,129,0.24); color:#bbf7d0; }
|
||||||
.message.bad { background:rgba(239,68,68,0.12); border:1px solid rgba(239,68,68,0.24); color:#fecaca; }
|
.message.bad { background:rgba(239,68,68,0.12); border:1px solid rgba(239,68,68,0.24); color:#fecaca; }
|
||||||
body[data-theme="light"] .message.good { color:#166534; }
|
body[data-theme="light"] .message.good { color:#166534; }
|
||||||
body[data-theme="light"] .message.bad { color:#991b1b; }
|
body[data-theme="light"] .message.bad { color:#991b1b; }
|
||||||
.spacer { flex:1 1 auto; }
|
.spacer { flex:1 1 auto; }
|
||||||
@media (max-width: 980px) { .two { grid-template-columns:1fr; } .wrap { width:min(96vw, 1560px); padding:16px; } }
|
@media (max-width: 980px) { .two { grid-template-columns:1fr; } .wrap { width:min(96vw, 1560px); padding:16px; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-theme="dark">
|
<body data-theme="dark">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a class="btn" href="/">Dashboard</a>
|
<a class="btn" href="/">Dashboard</a>
|
||||||
<a class="btn" href="/history">History</a>
|
<a class="btn" href="/history">History</a>
|
||||||
<a class="btn" href="/alarms">Alarms</a>
|
<a class="btn" href="/alarms">Alarms</a>
|
||||||
<a class="btn" href="/kiosk">Kiosk</a>
|
<a class="btn" href="/kiosk">Kiosk</a>
|
||||||
<a class="btn" href="/process-capability">Process capability</a>
|
<a class="btn" href="/process-capability">Process capability</a>
|
||||||
<a class="btn" href="/reports">Reports</a>
|
<a class="btn" href="/reports">Reports</a>
|
||||||
<a class="btn primary" href="/license">License</a>
|
<a class="btn primary" href="/license">License</a>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
<button id="theme-toggle" class="btn" type="button">Light theme</button>
|
||||||
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
|
||||||
<button id="refresh-btn" class="btn warn" type="button">Refresh</button>
|
<button id="refresh-btn" class="btn warn" type="button">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass card" style="margin-bottom:18px;">
|
<div class="glass card" style="margin-bottom:18px;">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<div class="kicker">Force Monitor</div>
|
<div class="kicker">Force Monitor</div>
|
||||||
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">License Center</h1>
|
<h1 style="margin:8px 0 0 0; font-size:40px; line-height:1;">License Center</h1>
|
||||||
<div class="sub">Status, fingerprint, activation request, local license import, and signed license activation.</div>
|
<div class="sub">Status, fingerprint, activation request, local license import, and signed license activation.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span id="mode-badge" class="badge info">mode: loading</span>
|
<span id="mode-badge" class="badge info">mode: loading</span>
|
||||||
<span id="lock-badge" class="badge">locked: --</span>
|
<span id="lock-badge" class="badge">locked: --</span>
|
||||||
<span id="tamper-badge" class="badge">tamper: --</span>
|
<span id="tamper-badge" class="badge">tamper: --</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="action-message" class="message"></div>
|
<div id="action-message" class="message"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid cards">
|
<div class="grid cards">
|
||||||
<div class="glass card"><div class="kicker">Current mode</div><div id="metric-mode" class="value">--</div><div id="metric-mode-sub" class="sub">Loading...</div></div>
|
<div class="glass card"><div class="kicker">Current mode</div><div id="metric-mode" class="value">--</div><div id="metric-mode-sub" class="sub">Loading...</div></div>
|
||||||
<div class="glass card"><div class="kicker">Days remaining</div><div id="metric-days" class="value mono">--</div><div class="sub">Trial or expiry information</div></div>
|
<div class="glass card"><div class="kicker">Days remaining</div><div id="metric-days" class="value mono">--</div><div class="sub">Trial or expiry information</div></div>
|
||||||
<div class="glass card"><div class="kicker">Activation configured</div><div id="metric-configured" class="value mono">--</div><div class="sub">Public key present or not</div></div>
|
<div class="glass card"><div class="kicker">Activation configured</div><div id="metric-configured" class="value mono">--</div><div class="sub">Public key present or not</div></div>
|
||||||
<div class="glass card"><div class="kicker">Fingerprint</div><div id="metric-fingerprint" class="value mono" style="font-size:24px;">--</div><div class="sub">Short fingerprint</div></div>
|
<div class="glass card"><div class="kicker">Fingerprint</div><div id="metric-fingerprint" class="value mono" style="font-size:24px;">--</div><div class="sub">Short fingerprint</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid two">
|
<div class="grid two">
|
||||||
<div class="glass card">
|
<div class="glass card">
|
||||||
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
|
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="kicker">Activation request</div>
|
<div class="kicker">Activation request</div>
|
||||||
<div class="sub">Send this JSON to your private signing tool or license issuer.</div>
|
<div class="sub">Send this JSON to your private signing tool or license issuer.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="copy-request-btn" class="btn" type="button">Copy</button>
|
<button id="copy-request-btn" class="btn" type="button">Copy</button>
|
||||||
<button id="download-request-btn" class="btn good" type="button">Download</button>
|
<button id="download-request-btn" class="btn good" type="button">Download</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre id="request-json">Loading activation request...</pre>
|
<pre id="request-json">Loading activation request...</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass card">
|
<div class="glass card">
|
||||||
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
|
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="kicker">Activate signed license</div>
|
<div class="kicker">Activate signed license</div>
|
||||||
<div class="sub">Paste the signed license JSON or load it from a local file.</div>
|
<div class="sub">Paste the signed license JSON or load it from a local file.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input id="license-file" type="file" accept="application/json,.json" class="btn" style="padding:8px 12px;">
|
<input id="license-file" type="file" accept="application/json,.json" class="btn" style="padding:8px 12px;">
|
||||||
<button id="activate-btn" class="btn primary" type="button">Activate</button>
|
<button id="activate-btn" class="btn primary" type="button">Activate</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="license-text" class="mono" placeholder='{"app":"force_monitor",...}'></textarea>
|
<textarea id="license-text" class="mono" placeholder='{"app":"force_monitor",...}'></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass card" style="margin-top:18px;">
|
<div class="glass card" style="margin-top:18px;">
|
||||||
<div class="kicker">Status details</div>
|
<div class="kicker">Status details</div>
|
||||||
<div class="details" style="margin-top:14px;">
|
<div class="details" style="margin-top:14px;">
|
||||||
<div class="detail-box"><div class="label">Message</div><div id="detail-message">--</div></div>
|
<div class="detail-box"><div class="label">Message</div><div id="detail-message">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Customer</div><div id="detail-customer">--</div></div>
|
<div class="detail-box"><div class="label">Customer</div><div id="detail-customer">--</div></div>
|
||||||
<div class="detail-box"><div class="label">License ID</div><div id="detail-license-id" class="mono">--</div></div>
|
<div class="detail-box"><div class="label">License ID</div><div id="detail-license-id" class="mono">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Hostname</div><div id="detail-hostname" class="mono">--</div></div>
|
<div class="detail-box"><div class="label">Hostname</div><div id="detail-hostname" class="mono">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Fingerprint</div><div id="detail-fingerprint" class="mono">--</div></div>
|
<div class="detail-box"><div class="label">Fingerprint</div><div id="detail-fingerprint" class="mono">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Trial window</div><div id="detail-trial-window">--</div></div>
|
<div class="detail-box"><div class="label">Trial window</div><div id="detail-trial-window">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Expires at</div><div id="detail-expires">--</div></div>
|
<div class="detail-box"><div class="label">Expires at</div><div id="detail-expires">--</div></div>
|
||||||
<div class="detail-box"><div class="label">Features</div><div id="detail-features">--</div></div>
|
<div class="detail-box"><div class="label">Features</div><div id="detail-features">--</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app-common.js"></script>
|
<script src="/static/app-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let activationRequestText = '';
|
let activationRequestText = '';
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value === undefined || value === null ? '' : value)
|
return String(value === undefined || value === null ? '' : value)
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
const t = theme === 'light' ? 'light' : 'dark';
|
const t = theme === 'light' ? 'light' : 'dark';
|
||||||
document.body.setAttribute('data-theme', t);
|
document.body.setAttribute('data-theme', t);
|
||||||
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
|
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
|
||||||
const btn = document.getElementById('theme-toggle');
|
const btn = document.getElementById('theme-toggle');
|
||||||
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
let theme = 'dark';
|
let theme = 'dark';
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('force-monitor-theme');
|
const stored = localStorage.getItem('force-monitor-theme');
|
||||||
if (stored === 'light' || stored === 'dark') theme = stored;
|
if (stored === 'light' || stored === 'dark') theme = stored;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessage(text, good) {
|
function setMessage(text, good) {
|
||||||
const box = document.getElementById('action-message');
|
const box = document.getElementById('action-message');
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
box.textContent = text || '';
|
box.textContent = text || '';
|
||||||
box.className = 'message' + (text ? ' show ' + (good ? 'good' : 'bad') : '');
|
box.className = 'message' + (text ? ' show ' + (good ? 'good' : 'bad') : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDetail(id, value) {
|
function setDetail(id, value) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = value || '--';
|
if (el) el.textContent = value || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(id, text, klass) {
|
function setBadge(id, text, klass) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.className = 'badge ' + (klass || '');
|
el.className = 'badge ' + (klass || '');
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
const mode = String(status.mode || '--');
|
const mode = String(status.mode || '--');
|
||||||
const locked = !!status.locked;
|
const locked = !!status.locked;
|
||||||
const tampered = !!status.tampered;
|
const tampered = !!status.tampered;
|
||||||
const activationConfigured = !!status.activation_configured;
|
const activationConfigured = !!status.activation_configured;
|
||||||
const daysRemaining = Number.isFinite(Number(status.days_remaining)) ? String(status.days_remaining) : '--';
|
const daysRemaining = Number.isFinite(Number(status.days_remaining)) ? String(status.days_remaining) : '--';
|
||||||
document.getElementById('metric-mode').textContent = mode.toUpperCase();
|
document.getElementById('metric-mode').textContent = mode.toUpperCase();
|
||||||
document.getElementById('metric-mode-sub').textContent = status.message || '--';
|
document.getElementById('metric-mode-sub').textContent = status.message || '--';
|
||||||
document.getElementById('metric-days').textContent = daysRemaining;
|
document.getElementById('metric-days').textContent = daysRemaining;
|
||||||
document.getElementById('metric-configured').textContent = activationConfigured ? 'YES' : 'NO';
|
document.getElementById('metric-configured').textContent = activationConfigured ? 'YES' : 'NO';
|
||||||
document.getElementById('metric-fingerprint').textContent = status.fingerprint_short || '--';
|
document.getElementById('metric-fingerprint').textContent = status.fingerprint_short || '--';
|
||||||
|
|
||||||
setBadge('mode-badge', 'mode: ' + mode, locked ? 'bad' : (mode === 'licensed' ? 'ok' : 'info'));
|
setBadge('mode-badge', 'mode: ' + mode, locked ? 'bad' : (mode === 'licensed' ? 'ok' : 'info'));
|
||||||
setBadge('lock-badge', 'locked: ' + (locked ? 'yes' : 'no'), locked ? 'bad' : 'ok');
|
setBadge('lock-badge', 'locked: ' + (locked ? 'yes' : 'no'), locked ? 'bad' : 'ok');
|
||||||
setBadge('tamper-badge', 'tamper: ' + (tampered ? 'yes' : 'no'), tampered ? 'bad' : 'ok');
|
setBadge('tamper-badge', 'tamper: ' + (tampered ? 'yes' : 'no'), tampered ? 'bad' : 'ok');
|
||||||
|
|
||||||
setDetail('detail-message', status.message || '--');
|
setDetail('detail-message', status.message || '--');
|
||||||
setDetail('detail-customer', status.customer || '--');
|
setDetail('detail-customer', status.customer || '--');
|
||||||
setDetail('detail-license-id', status.license_id || '--');
|
setDetail('detail-license-id', status.license_id || '--');
|
||||||
setDetail('detail-hostname', status.hostname || '--');
|
setDetail('detail-hostname', status.hostname || '--');
|
||||||
setDetail('detail-fingerprint', status.fingerprint || '--');
|
setDetail('detail-fingerprint', status.fingerprint || '--');
|
||||||
const trialWindow = (status.trial_started_at || '--') + ' → ' + (status.trial_expires_at || '--');
|
const trialWindow = (status.trial_started_at || '--') + ' → ' + (status.trial_expires_at || '--');
|
||||||
setDetail('detail-trial-window', trialWindow);
|
setDetail('detail-trial-window', trialWindow);
|
||||||
setDetail('detail-expires', status.expires_at || '--');
|
setDetail('detail-expires', status.expires_at || '--');
|
||||||
setDetail('detail-features', Array.isArray(status.features) && status.features.length ? status.features.join(', ') : '--');
|
setDetail('detail-features', Array.isArray(status.features) && status.features.length ? status.features.join(', ') : '--');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
try {
|
try {
|
||||||
const status = await AppUI.fetchJson('/api/license/status', { timeoutMs:8000 });
|
const status = await AppUI.fetchJson('/api/license/status', { timeoutMs:8000 });
|
||||||
renderStatus(status);
|
renderStatus(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('License status error:', err);
|
console.warn('License status error:', err);
|
||||||
setMessage('Could not load license status.', false);
|
setMessage('Could not load license status.', false);
|
||||||
document.getElementById('metric-mode').textContent = 'ERROR';
|
document.getElementById('metric-mode').textContent = 'ERROR';
|
||||||
document.getElementById('metric-mode-sub').textContent = err && err.message ? err.message : 'Could not load license status.';
|
document.getElementById('metric-mode-sub').textContent = err && err.message ? err.message : 'Could not load license status.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRequest() {
|
async function refreshRequest() {
|
||||||
try {
|
try {
|
||||||
const data = await AppUI.fetchJson('/api/license/request', { timeoutMs:8000 });
|
const data = await AppUI.fetchJson('/api/license/request', { timeoutMs:8000 });
|
||||||
activationRequestText = JSON.stringify(data, null, 2);
|
activationRequestText = JSON.stringify(data, null, 2);
|
||||||
document.getElementById('request-json').textContent = activationRequestText;
|
document.getElementById('request-json').textContent = activationRequestText;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('License request error:', err);
|
console.warn('License request error:', err);
|
||||||
activationRequestText = '';
|
activationRequestText = '';
|
||||||
document.getElementById('request-json').textContent = 'Could not load activation request: ' + (err && err.message ? err.message : 'unknown error');
|
document.getElementById('request-json').textContent = 'Could not load activation request: ' + (err && err.message ? err.message : 'unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateLicense() {
|
async function activateLicense() {
|
||||||
const text = document.getElementById('license-text').value.trim();
|
const text = document.getElementById('license-text').value.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
setMessage('Paste a signed license JSON first.', false);
|
setMessage('Paste a signed license JSON first.', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessage('Activating license...', true);
|
setMessage('Activating license...', true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/license/activate', {
|
const res = await fetch('/api/license/activate', {
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{ 'Content-Type':'application/json' },
|
headers:{ 'Content-Type':'application/json' },
|
||||||
body: JSON.stringify({ license_text:text })
|
body: JSON.stringify({ license_text:text })
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errMsg = data && data.error ? data.error : ('HTTP ' + res.status);
|
const errMsg = data && data.error ? data.error : ('HTTP ' + res.status);
|
||||||
setMessage('Activation failed: ' + errMsg, false);
|
setMessage('Activation failed: ' + errMsg, false);
|
||||||
if (data && data.license) renderStatus(data.license);
|
if (data && data.license) renderStatus(data.license);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessage('License activated successfully.', true);
|
setMessage('License activated successfully.', true);
|
||||||
if (data && data.license) renderStatus(data.license);
|
if (data && data.license) renderStatus(data.license);
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('License activate error:', err);
|
console.warn('License activate error:', err);
|
||||||
setMessage('Activation request failed.', false);
|
setMessage('Activation request failed.', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyRequest() {
|
async function copyRequest() {
|
||||||
if (!activationRequestText) return;
|
if (!activationRequestText) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(activationRequestText);
|
await navigator.clipboard.writeText(activationRequestText);
|
||||||
setMessage('Activation request copied to clipboard.', true);
|
setMessage('Activation request copied to clipboard.', true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Clipboard copy failed.', false);
|
setMessage('Clipboard copy failed.', false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadRequest() {
|
function downloadRequest() {
|
||||||
if (!activationRequestText) return;
|
if (!activationRequestText) return;
|
||||||
const blob = new Blob([activationRequestText], { type:'application/json;charset=utf-8' });
|
const blob = new Blob([activationRequestText], { type:'application/json;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'force-monitor-activation-request.json';
|
a.download = 'force-monitor-activation-request.json';
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachFileReader() {
|
function attachFileReader() {
|
||||||
const input = document.getElementById('license-file');
|
const input = document.getElementById('license-file');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.addEventListener('change', async () => {
|
input.addEventListener('change', async () => {
|
||||||
const file = input.files && input.files[0];
|
const file = input.files && input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
document.getElementById('license-text').value = text;
|
document.getElementById('license-text').value = text;
|
||||||
setMessage('Loaded license file into the text box.', true);
|
setMessage('Loaded license file into the text box.', true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
AppUI.initTheme();
|
AppUI.initTheme();
|
||||||
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
|
||||||
document.getElementById('refresh-btn').addEventListener('click', async () => { setMessage('', true); await Promise.all([refreshStatus(), refreshRequest()]); });
|
document.getElementById('refresh-btn').addEventListener('click', async () => { setMessage('', true); await Promise.all([refreshStatus(), refreshRequest()]); });
|
||||||
document.getElementById('activate-btn').addEventListener('click', activateLicense);
|
document.getElementById('activate-btn').addEventListener('click', activateLicense);
|
||||||
document.getElementById('copy-request-btn').addEventListener('click', copyRequest);
|
document.getElementById('copy-request-btn').addEventListener('click', copyRequest);
|
||||||
document.getElementById('download-request-btn').addEventListener('click', downloadRequest);
|
document.getElementById('download-request-btn').addEventListener('click', downloadRequest);
|
||||||
attachFileReader();
|
attachFileReader();
|
||||||
await Promise.all([refreshStatus(), refreshRequest()]);
|
await Promise.all([refreshStatus(), refreshRequest()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,28 @@
|
||||||
<!DOCTYPE html>
|
<!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>
|
<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>
|
<script src="/static/chart.umd.min.js"></script>
|
||||||
<style>
|
<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>
|
: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">
|
<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="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="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="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="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="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="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 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>
|
</div>
|
||||||
<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 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 fmt(n,d=2){return Number(n||0).toFixed(d)}
|
||||||
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)}}
|
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||'--';
|
||||||
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();}
|
||||||
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');
|
||||||
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>';}
|
||||||
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)}
|
||||||
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));
|
||||||
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>
|
||||||
</script></body></html>
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
<!DOCTYPE html>
|
<!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>
|
<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>
|
<script src="/static/chart.umd.min.js"></script>
|
||||||
<style>
|
<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>
|
: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">
|
<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="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="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="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="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">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">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 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>
|
</div>
|
||||||
<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){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 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 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>';}
|
||||||
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
|
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));
|
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>
|
</script></body></html>
|
||||||
Loading…
Reference in a new issue