Compare commits

...

32 commits
0.9.0 ... main

Author SHA1 Message Date
Dejan bc6bfc94b8 Delete activator/activator-force-monitor.exe 2026-04-30 16:52:53 +00:00
Dejan b0ed6ac499 Upload files to "activator" 2026-04-30 16:52:24 +00:00
Dejan R 3899c78a89 added docker-compose file and Dockerfile 2026-04-23 18:40:13 +02:00
Dejan R dc1b30a34e added install script for the activator on a server 2026-04-23 17:19:32 +02:00
Dejan ff029e4e81 go.mod + go.sub new files fix 2026-04-23 10:10:04 +00:00
Dejan 8c4121f32f Removed signing key from config file 2026-04-23 10:09:40 +00:00
Dejan 6cba2d15f6 added new licence function 2026-04-23 10:09:13 +00:00
Dejan e38ecfc037 added new static pages 2026-04-23 10:08:35 +00:00
Dejan Rožič 49860df5a0 added licence.go 2026-04-23 08:24:24 +02:00
Dejan R 3ca28c0e13 fix menu css selector and increase version in main.go 2026-04-22 19:23:53 +02:00
Dejan R 8aed4f57e2 new version bug fix 2026-04-22 16:19:59 +02:00
Dejan R 8c0b353c90 bug fix 2026-04-22 16:16:27 +02:00
Dejan R ea9bdddef4 fix fulscreen between btns 2026-04-22 10:57:15 +02:00
Dejan R 6e2cf09ce5 added for new version 2026-04-22 10:42:52 +02:00
Dejan Rožič bf435f9abf added stacitc pages for alarms, dashbord etc 2026-04-21 12:37:18 +02:00
Dejan Rožič 4af3ce0d88 increase version 2026-04-21 12:36:00 +02:00
Dejan Rožič 0ce398fbda added pages for history, licence,alarms 2026-04-21 12:34:48 +02:00
Dejan Rožič 3891d9b61d the page uses server-side stale detection first, so another PC with wrong clock should not falsely blur the UI 2026-04-21 09:07:28 +02:00
Dejan Rožič c9ffe85d54 /api/data now returns stale and server_time 2026-04-21 09:07:06 +02:00
Dejan Rožič a4ff3a87c1 added static page at buildtime 2026-04-21 06:58:24 +02:00
Dejan Rožič ec65e3494c added static page at buildtime 2026-04-21 06:58:21 +02:00
Dejan 55d723997d Upload files to "activator" 2026-04-20 16:29:31 +00:00
Dejan R 950ae0a4e9 added page to main 2026-04-20 18:11:26 +02:00
Dejan R 81018b8aba removed index html from main 2026-04-20 18:05:11 +02:00
Dejan R f6f3762bb5 added licence.go 2026-04-20 18:04:18 +02:00
Dejan R e6354c27d5 added index.html 2026-04-20 18:03:56 +02:00
Dejan R f44b1f6ebc added readme.md 2026-04-20 18:01:16 +02:00
Dejan R f60ca88a09 added activator subfolder 2026-04-20 16:25:52 +02:00
Dejan Rožič 2d11e33039 added bug fixes 2026-04-20 06:25:12 +02:00
Dejan R 79e5a0e269 added mqtt and API for rest/API , verius bug fixes 2026-04-19 17:42:03 +02:00
Dejan R 6b83d6d058 Update Go dependencies 2026-04-19 12:48:57 +02:00
Dejan R 4ddf124174 added autorereload and API 2026-04-19 12:44:28 +02:00
22 changed files with 7206 additions and 2176 deletions

14
activator/Dockerfile Normal file
View 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"]

125
activator/README.md Normal file
View file

@ -0,0 +1,125 @@
# License Activation App
Small offline Go web app for generating signed licenses for the protected `force_monitor` application.
## What this app does
- generates Ed25519 keypairs
- parses activation request JSON copied from the target machine
- creates signed license JSON bound to that machine fingerprint
- shows the public key that must be configured in the protected app
## Important deployment answer
**Recommended setup:**
- Run the **activator on your laptop** or on a secure internal office PC.
- Run the **protected app on the machine PC**.
- **Do not** ship the activator together with the machine app.
- **Do not** place the private signing key on the customer machine.
### Why
The protected machine should only contain the **public key** so it can verify licenses.
The activator should keep the **private key** secret, because the private key is what creates valid licenses.
If the private key is copied to the machine, anyone with access to that PC could generate licenses.
## Recommended workflow
1. Install the protected app on the target machine.
2. Open the protected app in browser.
3. Copy the activation request JSON from:
- `GET /api/license/request`
- or the activation page if you added one in the UI.
4. Run this activator on your laptop:
```bash
go run .
```
5. Open:
```text
http://localhost:8090
```
6. Paste the activation request JSON.
7. Generate or load your signing key.
8. Generate the signed license JSON.
9. Copy that license JSON back to the target machine.
10. Activate it in the protected app using:
- `POST /api/license/activate`
- or the protected app activation page.
## First-time key setup
You only need to create the signing keypair once.
### Option A — generate in the activator UI
- Click **Generate new keypair**
- Save the private key somewhere safe
- Copy the public key into the protected app config:
```yaml
license:
public_key_base64: "PASTE_PUBLIC_KEY_HERE"
```
### Option B — use environment variable for the private key
Set this before running the activator:
### Windows PowerShell
```powershell
$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
go run .
```
### Linux/macOS shell
```bash
export LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
go run .
```
Then the UI can generate licenses without pasting the private key each time.
## Project files
- `main.go` — activator web app
- `go.mod` — module definition
- `README.md` — usage instructions
## Run
```bash
go run .
```
Then open:
```text
http://localhost:8090
```
## Optional environment variables
- `ACTIVATOR_LISTEN_ADDR` — default `:8090`
- `ACTIVATOR_DEFAULT_PRODUCT` — default `force_monitor`
- `LICENSE_PRIVATE_KEY_BASE64` — private signing key
Example:
```powershell
$env:ACTIVATOR_LISTEN_ADDR=":8095"
$env:ACTIVATOR_DEFAULT_PRODUCT="force_monitor"
$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE"
go run .
```
## Practical recommendation
For your case, the safest and cleanest model is:
- laptop/office PC = **license generator / activator**
- machine PC = **protected runtime app only**
That way you can activate many customer machines without exposing your private signing key.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,7 @@
services:
tonnage-activator:
build: .
container_name: tonnage-activator
restart: unless-stopped
ports:
- "8099:8090"

3
activator/go.mod Normal file
View file

@ -0,0 +1,3 @@
module licence-activation-app
go 1.22

143
activator/install.sh Executable file
View 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 ""

564
activator/main.go Normal file
View file

@ -0,0 +1,564 @@
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
"time"
)
const appVersion = "1.0.0"
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 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 keyPairResponse struct {
PublicKeyBase64 string `json:"public_key_base64"`
PrivateKeyBase64 string `json:"private_key_base64"`
}
type generateLicenseRequest struct {
PrivateKeyBase64 string `json:"private_key_base64"`
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"`
Features string `json:"features"`
}
type generateLicenseResponse struct {
License SignedLicense `json:"license"`
LicenseJSON string `json:"license_json"`
PublicKeyBase64 string `json:"public_key_base64"`
PayloadJSON string `json:"payload_json"`
}
type parseRequestResponse struct {
Request ActivationRequest `json:"request"`
}
var pageTmpl = template.Must(template.New("index").Parse(indexHTML))
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handleIndex)
mux.HandleFunc("/api/health", handleHealth)
mux.HandleFunc("/api/keypair", handleKeypair)
mux.HandleFunc("/api/request/parse", handleRequestParse)
mux.HandleFunc("/api/license/generate", handleGenerateLicense)
addr := envOrDefault("ACTIVATOR_LISTEN_ADDR", ":8090")
log.Printf("License activator %s listening on http://localhost%s", appVersion, addr)
if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil {
log.Fatal(err)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
data := struct {
Version string
DefaultProduct string
EnvPrivateKeyPresent bool
}{
Version: appVersion,
DefaultProduct: envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor"),
EnvPrivateKeyPresent: strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64")) != "",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pageTmpl.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": appVersion,
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func handleKeypair(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r, http.MethodPost) {
return
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, keyPairResponse{
PublicKeyBase64: base64.StdEncoding.EncodeToString(pub),
PrivateKeyBase64: base64.StdEncoding.EncodeToString(priv),
})
}
func handleRequestParse(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r, http.MethodPost) {
return
}
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err))
return
}
var req ActivationRequest
if err := json.Unmarshal([]byte(strings.TrimSpace(body.Text)), &req); err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid activation request json: %w", err))
return
}
writeJSON(w, http.StatusOK, parseRequestResponse{Request: req})
}
func handleGenerateLicense(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r, http.MethodPost) {
return
}
var req generateLicenseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err))
return
}
privateKeyBase64 := strings.TrimSpace(req.PrivateKeyBase64)
if privateKeyBase64 == "" {
privateKeyBase64 = strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64"))
}
if privateKeyBase64 == "" {
writeError(w, http.StatusBadRequest, errors.New("private_key_base64 is required or set LICENSE_PRIVATE_KEY_BASE64"))
return
}
lic, payloadJSON, pubKey, err := buildSignedLicense(req, privateKeyBase64)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
b, err := json.MarshalIndent(lic, "", " ")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, generateLicenseResponse{
License: lic,
LicenseJSON: string(b),
PublicKeyBase64: pubKey,
PayloadJSON: payloadJSON,
})
}
func buildSignedLicense(req generateLicenseRequest, privateKeyBase64 string) (SignedLicense, string, string, error) {
app := strings.TrimSpace(req.App)
if app == "" {
app = envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor")
}
lic := SignedLicense{
App: app,
LicenseID: strings.TrimSpace(req.LicenseID),
Customer: strings.TrimSpace(req.Customer),
Fingerprint: normalizeFingerprint(req.Fingerprint),
IssuedAt: strings.TrimSpace(req.IssuedAt),
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
Features: parseFeatures(req.Features),
}
if lic.LicenseID == "" {
lic.LicenseID = "LIC-" + time.Now().UTC().Format("20060102-150405")
}
if lic.Fingerprint == "" {
return SignedLicense{}, "", "", errors.New("fingerprint is required")
}
if lic.IssuedAt == "" {
lic.IssuedAt = time.Now().UTC().Format(time.RFC3339)
}
if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil {
return SignedLicense{}, "", "", fmt.Errorf("issued_at must be RFC3339, example 2026-04-20T12:00:00Z")
}
if lic.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339, lic.ExpiresAt); err != nil {
return SignedLicense{}, "", "", fmt.Errorf("expires_at must be RFC3339, example 2027-04-20T00:00:00Z")
}
}
privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64))
if err != nil {
return SignedLicense{}, "", "", fmt.Errorf("decode private key: %w", err)
}
if len(privRaw) != ed25519.PrivateKeySize {
return SignedLicense{}, "", "", fmt.Errorf("invalid private key size: got %d want %d", len(privRaw), ed25519.PrivateKeySize)
}
priv := ed25519.PrivateKey(privRaw)
payload := licensePayload{
App: lic.App,
LicenseID: lic.LicenseID,
Customer: lic.Customer,
Fingerprint: lic.Fingerprint,
IssuedAt: lic.IssuedAt,
ExpiresAt: lic.ExpiresAt,
Features: lic.Features,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return SignedLicense{}, "", "", fmt.Errorf("marshal payload: %w", err)
}
sig := ed25519.Sign(priv, payloadBytes)
lic.Signature = base64.StdEncoding.EncodeToString(sig)
pub := priv.Public().(ed25519.PublicKey)
return lic, string(payloadBytes), base64.StdEncoding.EncodeToString(pub), nil
}
func parseFeatures(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func normalizeFingerprint(v string) string {
v = strings.TrimSpace(strings.ToUpper(v))
v = strings.ReplaceAll(v, "-", "")
v = strings.ReplaceAll(v, " ", "")
return v
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool {
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
return false
}
if r.Method != method {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return false
}
return true
}
func envOrDefault(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}
const indexHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>License Activator</title>
<style>
:root {
--bg: #0b1020;
--panel: #141b2d;
--panel2: #1a2339;
--text: #edf2ff;
--muted: #9fb0d1;
--line: #2b3755;
--accent: #5aa9ff;
--ok: #36c275;
--warn: #f2b94b;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Segoe UI, Arial, sans-serif; background: linear-gradient(180deg, #08101f, #0d1526); color: var(--text); }
.wrap { max-width: 1360px; margin: 0 auto; padding: 24px; }
.hero { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:20px; }
.tag { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; color: var(--muted); font-size:12px; }
.grid { display:grid; grid-template-columns: 1.2fr 1fr; gap:20px; }
.card { background: rgba(20,27,45,0.92); border:1px solid var(--line); border-radius:18px; padding:18px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
h1,h2,h3 { margin:0 0 10px 0; }
h1 { font-size: 34px; }
h2 { font-size: 21px; }
p { color: var(--muted); }
label { display:block; font-size:13px; color: var(--muted); margin: 0 0 6px 2px; }
input, textarea, button { width:100%; border-radius:12px; border:1px solid var(--line); background: #0d1526; color: var(--text); padding:12px 14px; }
textarea { min-height: 120px; resize: vertical; }
button { background: linear-gradient(180deg, #2579ff, #1f67d8); font-weight:700; cursor:pointer; }
button.secondary { background: #1a2339; }
button.ghost { background: transparent; }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
.row3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:12px; }
.stack { display:flex; flex-direction:column; gap:14px; }
.toolbar { display:flex; gap:10px; flex-wrap:wrap; }
.toolbar button { width:auto; min-width:170px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.out { min-height:220px; }
.small { font-size:12px; color: var(--muted); }
.notice { padding:12px 14px; border-radius:14px; border:1px solid var(--line); background:#10192d; color:var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.top-note { margin-top:8px; }
@media (max-width: 1000px) { .grid, .row, .row3 { grid-template-columns: 1fr; } .hero { flex-direction:column; } }
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<div>
<div class="tag">License Activator v{{.Version}}</div>
<h1>Offline License Generator</h1>
<p>Create Ed25519 keypairs, parse activation requests from the machine, and generate signed license JSON for your protected Go app.</p>
</div>
<div class="card" style="min-width:320px; max-width:420px;">
<h3>Important</h3>
<p class="small">Keep this activator private. Do not ship your private signing key with the customer machine. Only the public key belongs in the protected app config.</p>
<div class="small top-note">Env private key loaded: <span class="{{if .EnvPrivateKeyPresent}}ok{{else}}warn{{end}}">{{if .EnvPrivateKeyPresent}}yes{{else}}no{{end}}</span></div>
<div class="small">Default product code: <span class="mono">{{.DefaultProduct}}</span></div>
</div>
</div>
<div class="grid">
<div class="stack">
<div class="card stack">
<h2>1. Activation request from machine</h2>
<label for="requestJson">Paste machine activation request JSON</label>
<textarea id="requestJson" class="mono" placeholder='{"app":"force_monitor","fingerprint":"ABC..."}'></textarea>
<div class="toolbar">
<button type="button" class="secondary" id="parseRequestBtn">Parse request into form</button>
<button type="button" class="ghost" id="clearRequestBtn">Clear request</button>
</div>
<div id="requestStatus" class="notice small">Paste the JSON from <span class="mono">/api/license/request</span> of the protected machine, then parse it.</div>
</div>
<div class="card stack">
<h2>2. License data</h2>
<div class="row3">
<div>
<label for="app">App / product code</label>
<input id="app" value="{{.DefaultProduct}}">
</div>
<div>
<label for="licenseId">License ID</label>
<input id="licenseId" placeholder="LIC-20260420-0001">
</div>
<div>
<label for="customer">Customer</label>
<input id="customer" placeholder="IMCO">
</div>
</div>
<div>
<label for="fingerprint">Machine fingerprint</label>
<input id="fingerprint" class="mono" placeholder="paste or auto-fill from request">
</div>
<div class="row">
<div>
<label for="issuedAt">Issued at (RFC3339)</label>
<input id="issuedAt" class="mono" placeholder="2026-04-20T12:00:00Z">
</div>
<div>
<label for="expiresAt">Expires at (optional RFC3339)</label>
<input id="expiresAt" class="mono" placeholder="2027-04-20T00:00:00Z">
</div>
</div>
<div>
<label for="features">Features (comma separated)</label>
<input id="features" placeholder="core,mqtt">
</div>
</div>
<div class="card stack">
<h2>3. Signing key</h2>
<label for="privateKey">Private key base64</label>
<textarea id="privateKey" class="mono" placeholder="paste Ed25519 private key base64 here or use environment variable LICENSE_PRIVATE_KEY_BASE64"></textarea>
<div class="toolbar">
<button type="button" class="secondary" id="generateKeypairBtn">Generate new keypair</button>
<button type="button" id="generateLicenseBtn">Generate signed license</button>
</div>
<div class="small">The generated public key must be copied into the protected app config as <span class="mono">license.public_key_base64</span>.</div>
</div>
</div>
<div class="stack">
<div class="card stack">
<h2>Generated keypair</h2>
<label for="publicKeyOut">Public key base64</label>
<textarea id="publicKeyOut" class="mono out" readonly></textarea>
<label for="privateKeyOut">Private key base64</label>
<textarea id="privateKeyOut" class="mono out" readonly></textarea>
</div>
<div class="card stack">
<h2>Signed license JSON</h2>
<label for="payloadOut">Canonical payload used for signing</label>
<textarea id="payloadOut" class="mono out" readonly></textarea>
<label for="licenseOut">License JSON to copy into the machine</label>
<textarea id="licenseOut" class="mono out" readonly></textarea>
<div id="licenseStatus" class="notice small">Generate a license and copy the JSON into the protected machine activation page.</div>
</div>
</div>
</div>
</div>
<script>
async function postJSON(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch (_) {}
if (!res.ok) {
throw new Error(data && data.error ? data.error : text || ('HTTP ' + res.status));
}
return data;
}
function setNowIfEmpty() {
const el = document.getElementById('issuedAt');
if (el && !el.value.trim()) {
el.value = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
}
}
function setStatus(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function fillFromRequest(req) {
if (!req) return;
if (req.app) document.getElementById('app').value = req.app;
if (req.fingerprint) document.getElementById('fingerprint').value = req.fingerprint;
setStatus('requestStatus', 'Loaded request for ' + (req.hostname || 'unknown host') + ' fingerprint ' + (req.fingerprint_short || req.fingerprint || ''));
}
document.getElementById('parseRequestBtn').addEventListener('click', async () => {
try {
const text = document.getElementById('requestJson').value.trim();
const data = await postJSON('/api/request/parse', { text });
fillFromRequest(data.request);
} catch (err) {
setStatus('requestStatus', err.message);
}
});
document.getElementById('clearRequestBtn').addEventListener('click', () => {
document.getElementById('requestJson').value = '';
setStatus('requestStatus', 'Request cleared.');
});
document.getElementById('generateKeypairBtn').addEventListener('click', async () => {
try {
const data = await postJSON('/api/keypair', {});
document.getElementById('publicKeyOut').value = data.public_key_base64 || '';
document.getElementById('privateKeyOut').value = data.private_key_base64 || '';
document.getElementById('privateKey').value = data.private_key_base64 || '';
setStatus('licenseStatus', 'New keypair generated. Put the public key into the protected app config. Keep the private key secret.');
} catch (err) {
setStatus('licenseStatus', err.message);
}
});
document.getElementById('generateLicenseBtn').addEventListener('click', async () => {
try {
setNowIfEmpty();
const body = {
private_key_base64: document.getElementById('privateKey').value,
app: document.getElementById('app').value,
license_id: document.getElementById('licenseId').value,
customer: document.getElementById('customer').value,
fingerprint: document.getElementById('fingerprint').value,
issued_at: document.getElementById('issuedAt').value,
expires_at: document.getElementById('expiresAt').value,
features: document.getElementById('features').value,
};
const data = await postJSON('/api/license/generate', body);
document.getElementById('payloadOut').value = data.payload_json || '';
document.getElementById('licenseOut').value = data.license_json || '';
if (data.public_key_base64) document.getElementById('publicKeyOut').value = data.public_key_base64;
setStatus('licenseStatus', 'License generated successfully. Copy the JSON into the target machine activation page or save it as license.json.');
} catch (err) {
setStatus('licenseStatus', err.message);
}
});
setNowIfEmpty();
</script>
</body>
</html>`

351
activator/readme.md Normal file
View file

@ -0,0 +1,351 @@
# Force Monitor — License Activation Guide
This document explains how the licensing system works and how to activate **Force Monitor** after the trial period.
---
## 📋 Table of Contents
1. [How Licensing Works](#how-licensing-works)
2. [Trial Period](#trial-period)
3. [Getting Your Machine Fingerprint](#getting-your-machine-fingerprint)
4. [Activating Your License](#activating-your-license)
5. [License File Format](#license-file-format)
6. [Troubleshooting](#troubleshooting)
7. [For Vendors: Generating Licenses](#for-vendors-generating-licenses)
---
## How Licensing Works
Force Monitor uses **Ed25519 cryptographic signatures** to verify licenses. The system supports:
- **Trial mode** — free for a limited time (default: 7 days)
- **Paid license** — perpetual or time-limited, tied to a specific machine
### Key Concepts
| Term | Description |
|------|-------------|
| **Machine Fingerprint** | Unique hardware ID derived from your machine (MAC, motherboard serial, etc.) |
| **Public Key** | Embedded in the app; used to verify license signatures |
| **Private Key** | Kept secret by the vendor; used to sign licenses |
| **Signed License** | JSON file cryptographically signed by the vendor |
---
## Trial Period
When you first run Force Monitor, it automatically starts a **7-day trial**:
```bash
# Check your current license status
curl http://localhost:8080/api/license/status
```
**Example response (trial active):**
```json
{
"enabled": true,
"mode": "trial",
"locked": false,
"message": "trial active: 6 day(s) remaining",
"days_remaining": 6,
"trial_started_at": "2026-04-20T17:00:00Z",
"trial_expires_at": "2026-04-27T17:00:00Z",
"fingerprint_short": "A1B2C3D4-...",
"activation_configured": true
}
```
### Trial Expiration
After the trial expires, if `require_after_trial: true` (default), the app will:
- Return **HTTP 403 Forbidden** on all data endpoints
- Show a license activation page at `http://localhost:8080/`
- Display `"trial expired; activation required"`
---
## Getting Your Machine Fingerprint
To request a license, you need your **machine fingerprint**. Get it via API or the web UI.
### Method 1: API (Recommended)
```bash
curl -s http://localhost:8080/api/license/request | python3 -m json.tool
```
**Example response:**
```json
{
"app": "force_monitor",
"version": "1.0.0",
"generated_at": "2026-04-20T17:30:00Z",
"hostname": "press-control-01",
"platform": "linux/amd64",
"fingerprint": "A1B2C3D4E5F6789012345678901234567890ABCDEF...",
"fingerprint_short": "A1B2C3D4-E5F67890",
"components": [
"machineid=1234567890abcdef...",
"product_uuid=ABCDEF12-3456-7890-ABCD-EF1234567890",
"hostname=press-control-01",
"mac=aa:bb:cc:dd:ee:ff"
]
}
```
### Method 2: Web UI
1. Open `http://localhost:8080/` in your browser
2. If no `static/index.html` exists, a fallback page shows your **fingerprint** and **license mode**
3. Click **GET /api/license/request** to see the full activation request
---
## Activating Your License
Once you receive a signed license from the vendor, activate it using one of these methods:
### Method 1: Web UI (Easiest)
1. Open `http://localhost:8080/` in your browser
2. Paste the signed license JSON into the textarea
3. Click **Activate license**
![Activation UI](docs/activation-ui.png)
### Method 2: API (cURL)
```bash
# Save your license to a file
cat > license.json << 'EOF'
{
"app": "force_monitor",
"license_id": "LIC-2026-001",
"customer": "Your Company Name",
"fingerprint": "A1B2C3D4E5F6789012345678901234567890ABCDEF...",
"issued_at": "2026-04-20T17:00:00Z",
"expires_at": "2027-04-20T17:00:00Z",
"features": [],
"signature": "base64_encoded_ed25519_signature..."
}
EOF
# Activate via API
curl -X POST http://localhost:8080/api/license/activate \
-H "Content-Type: application/json" \
-d @license.json
```
**Success response:**
```json
{
"status": "activated",
"license": {
"mode": "licensed",
"locked": false,
"message": "license active",
"customer": "Your Company Name",
"license_id": "LIC-2026-001",
"expires_at": "2027-04-20T17:00:00Z"
}
}
```
### Method 3: Direct File Placement
You can also place the license file directly:
```bash
# Copy the signed license to the license directory
cp license.json license/license.json
# Restart the app
```
---
## License File Format
A valid signed license is a JSON file with this structure:
```json
{
"app": "force_monitor",
"license_id": "LIC-2026-001",
"customer": "Your Company Name",
"fingerprint": "A1B2C3D4E5F6789012345678901234567890ABCDEF...",
"issued_at": "2026-04-20T17:00:00Z",
"expires_at": "2027-04-20T17:00:00Z",
"features": ["premium", "mqtt"],
"signature": "base64_encoded_ed25519_signature..."
}
```
### Field Descriptions
| Field | Required | Description |
|-------|----------|-------------|
| `app` | ✅ | Must match `product_code` in config (`force_monitor`) |
| `license_id` | ✅ | Unique license identifier |
| `customer` | ✅ | Customer name |
| `fingerprint` | ✅ | Must match the machine fingerprint exactly |
| `issued_at` | ✅ | ISO 8601 timestamp when license was issued |
| `expires_at` | ❌ | Optional expiration date (omitted = perpetual) |
| `features` | ❌ | Optional feature flags array |
| `signature` | ✅ | Ed25519 signature of the payload, base64-encoded |
---
## Troubleshooting
### "trial state invalid or tampered"
**Cause:** System clock was changed, or trial files were modified.
**Fix:**
```bash
# Remove trial state and restart
rm license/trial_state.json
# Restart the app — a new trial will begin
```
### "license fingerprint does not match this machine"
**Cause:** The license was generated for a different machine.
**Fix:** Generate a new activation request on this machine and request a new license.
### "invalid license signature"
**Cause:** The license was not signed with the correct private key, or was corrupted.
**Fix:**
- Verify the license file wasn't modified
- Ensure the vendor used the correct private key matching your `public_key_base64`
### "no license public key configured"
**Cause:** `license.public_key_base64` is missing or invalid in `config.yaml`.
**Fix:** Add the vendor's public key to your config:
```yaml
license:
enabled: true
public_key_base64: "YOUR_BASE64_PUBLIC_KEY_HERE"
```
### Check Current Status
```bash
curl -s http://localhost:8080/api/license/status | python3 -m json.tool
```
---
## For Vendors: Generating Licenses
This repository includes helper functions for license generation. Use them in a **separate, private signing tool**.
### 1. Generate Ed25519 Key Pair
```go
package main
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
)
func main() {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
panic(err)
}
fmt.Println("Public Key (base64):")
fmt.Println(base64.StdEncoding.EncodeToString(publicKey))
fmt.Println("\nPrivate Key (base64) — KEEP SECRET:")
fmt.Println(base64.StdEncoding.EncodeToString(privateKey))
}
```
### 2. Sign a License
Use the `SignLicenseWithPrivateKey` helper from `license.go`:
```go
package main
import (
"encoding/json"
"fmt"
"time"
)
func main() {
lic := SignedLicense{
App: "force_monitor",
LicenseID: "LIC-2026-001",
Customer: "Customer Name",
Fingerprint: "A1B2C3D4E5F6789012345678901234567890ABCDEF...",
IssuedAt: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: time.Now().AddDate(1, 0, 0).UTC().Format(time.RFC3339),
Features: []string{},
}
privateKeyBase64 := "YOUR_PRIVATE_KEY_BASE64"
signedLic, err := SignLicenseWithPrivateKey(lic, privateKeyBase64)
if err != nil {
panic(err)
}
out, _ := json.MarshalIndent(signedLic, "", " ")
fmt.Println(string(out))
}
```
### 3. Distribute the Public Key
Give customers the **public key** to add to their `config.yaml`:
```yaml
license:
enabled: true
public_key_base64: "LLQ43Fle4nObHxmMQQsANvPUX5vDxx0TctpvQs+RI4s="
```
---
## Configuration Reference
```yaml
license:
enabled: true # Enable/disable licensing
trial_days: 7 # Trial period duration
require_after_trial: true # Lock app after trial expires
data_dir: license # Directory for license files
public_key_base64: "" # Ed25519 public key for verification
product_code: force_monitor # App identifier (must match license)
```
---
## API Reference
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/license/status` | GET | Get current license status |
| `/api/license/request` | GET | Get activation request (fingerprint) |
| `/api/license/activate` | POST | Submit signed license JSON |
---
## License
This project is proprietary software. Contact your vendor for licensing inquiries.

View file

@ -1,7 +1,7 @@
server:
listen_addr: :8080
plc:
ip: 192.168.0.1
ip: 192.168.1.205
db_num: 1001
rack: 0
slot: 1
@ -11,16 +11,52 @@ plc:
reconnect_delay_sec: 5
thresholds:
warning_percent: 80
critical_percent: 100
critical_percent: 95
gauge_max_percent: 130
imbalance_warning_percent: 15
imbalance_critical_percent: 25
trend:
minutes: 5
press:
MAX_TONNAGE: 63
MAX_TONNAGE: 320
ui:
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
right_label: DESNI STEBER
unit_force: kN
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

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module tonnage-app-imco
go 1.24.0
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/fsnotify/fsnotify v1.9.0
github.com/mattn/go-sqlite3 v1.14.42
github.com/robinson/gos7 v0.0.0-20241205073040-7ea1d6fb9d20
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
)

20
go.sum Normal file
View file

@ -0,0 +1,20 @@
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
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/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/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/go.mod h1:AMHIeh1KJ7Xa2RVOMHdv9jXKrpw0D4EWGGQMHLb2doc=
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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

645
licence.go Normal file
View 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
}

4437
main.go

File diff suppressed because it is too large Load diff

379
static/alarms.html Normal file
View file

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — Alarms</title>
<style>
:root {
--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);
--tableHover:rgba(255,255,255,0.04);
}
body[data-theme="light"] {
--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);
--tableHover:rgba(15,23,42,0.04);
}
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; color:var(--text); font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
body[data-theme="light"] {
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
.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); }
.nav, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.btn, .input, select {
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
padding:10px 14px; font:inherit;
}
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
.btn:hover { transform:translateY(-1px); }
.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.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
.input { width:100%; }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
.card { padding:18px 20px; }
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
.value { font-size:34px; font-weight:800; margin-top:8px; }
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
.pill {
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;
}
.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.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); }
body[data-theme="light"] .pill.info { color:#1d4ed8; }
body[data-theme="light"] .pill.warning { color:#b45309; }
body[data-theme="light"] .pill.critical { color:#dc2626; }
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; }
table { width:100%; border-collapse:collapse; }
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; }
tbody tr:hover { background:var(--tableHover); }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.right { text-align:right; }
.toolbar-wrap { padding:18px 20px; margin-bottom:18px; }
.table-wrap { padding:0 0 6px 0; overflow:auto; }
.error, .empty, .hint { color:var(--muted); }
.banner {
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;
}
.banner.show { display:block; }
.row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.spacer { flex:1 1 auto; }
@media (max-width: 900px) {
.wrap { width:min(96vw, 1680px); padding:16px; }
.value { font-size:28px; }
th:nth-child(5), td:nth-child(5), th:nth-child(6), td:nth-child(6) { display:none; }
}
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<a class="btn" href="/">Dashboard</a>
<a class="btn" href="/history">History</a>
<a class="btn primary" href="/alarms">Alarms</a>
<a class="btn" href="/kiosk">Kiosk</a>
<a class="btn" href="/process-capability">Process capability</a>
<a class="btn" href="/reports">Reports</a>
<a class="btn" href="/license">License</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</button>
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
</div>
<div class="glass card" style="margin-bottom:18px;">
<div class="row">
<div>
<div class="kicker">Force Monitor</div>
<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>
<div class="spacer"></div>
<div class="status-line">
<span id="fetch-status">Status: idle</span>
<span id="last-refresh">Last refresh: --</span>
</div>
</div>
</div>
<div id="license-warning" class="banner"></div>
<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">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">Warning</div><div id="metric-warning" class="value mono">0</div><div class="sub">Severity warning</div></div>
</div>
<div class="glass toolbar-wrap">
<div class="toolbar">
<select id="limit-select" title="Fetch limit">
<option value="20">20 rows</option>
<option value="50" selected>50 rows</option>
<option value="100">100 rows</option>
</select>
<select id="severity-filter" title="Severity filter">
<option value="all">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select id="source-filter" title="Source filter">
<option value="all">All sources</option>
<option value="plc">PLC</option>
<option value="force_left">Left force</option>
<option value="force_right">Right force</option>
<option value="imbalance">Imbalance</option>
</select>
<select id="state-filter" title="State filter">
<option value="all">All states</option>
<option value="active">Active only</option>
<option value="clear">Clear only</option>
<option value="info">Info only</option>
</select>
<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>
<button id="refresh-btn" class="btn primary" type="button">Refresh now</button>
<button id="export-btn" class="btn good" type="button">Export CSV</button>
</div>
<div class="status-line" style="margin-top:12px;">
<span>Tip: “active only” helps operators see what still matters right now.</span>
</div>
</div>
<div class="glass table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Severity</th>
<th>Source</th>
<th>State</th>
<th>Event</th>
<th class="right">Value</th>
<th class="right">Limit</th>
</tr>
</thead>
<tbody id="alarm-body">
<tr><td colspan="7" class="empty" style="padding:24px 10px; text-align:center;">Loading alarms...</td></tr>
</tbody>
</table>
</div>
</div>
<script src="/static/app-common.js"></script>
<script>
let allEvents = [];
let refreshTimer = null;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function setTheme(theme) {
const t = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', t);
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') theme = stored;
} catch (e) {}
setTheme(theme);
}
function formatPct(value) {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(1) + '%' : '--';
}
function formatSource(source) {
return ({ force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' })[source] || String(source || '').toUpperCase();
}
function severityPill(severity, state) {
const sev = String(severity || 'info').toLowerCase();
const klass = state === 'clear' ? 'clear' : sev;
const label = state === 'clear' ? 'CLEAR' : sev.toUpperCase();
return '<span class="pill ' + escapeHtml(klass) + '">' + escapeHtml(label) + '</span>';
}
function setMetrics(events) {
const active = events.filter(e => String(e.state || '').toLowerCase() === 'active').length;
const critical = events.filter(e => String(e.severity || '').toLowerCase() === 'critical').length;
const warning = events.filter(e => String(e.severity || '').toLowerCase() === 'warning').length;
document.getElementById('metric-total').textContent = String(events.length);
document.getElementById('metric-active').textContent = String(active);
document.getElementById('metric-critical').textContent = String(critical);
document.getElementById('metric-warning').textContent = String(warning);
document.getElementById('metric-total-sub').textContent = events.length === allEvents.length ? 'Current fetched set' : 'Filtered view';
}
function renderTable(events) {
const body = document.getElementById('alarm-body');
if (!body) return;
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>';
setMetrics(events);
return;
}
let html = '';
for (const ev of events) {
const state = String(ev.state || '').toLowerCase();
const value = ev.source === 'plc' ? '--' : formatPct(ev.value);
const limit = Number(ev.limit) > 0 ? formatPct(ev.limit) : '--';
html += '<tr>' +
'<td class="mono">' + escapeHtml(ev.time || '--') + '</td>' +
'<td>' + severityPill(ev.severity, state) + '</td>' +
'<td style="font-weight:700;">' + escapeHtml(formatSource(ev.source)) + '</td>' +
'<td class="mono">' + escapeHtml(state || '--') + '</td>' +
'<td>' + escapeHtml(ev.message || '--') + '</td>' +
'<td class="right mono">' + escapeHtml(value) + '</td>' +
'<td class="right mono">' + escapeHtml(limit) + '</td>' +
'</tr>';
}
body.innerHTML = html;
setMetrics(events);
}
function getFilters() {
return {
severity: document.getElementById('severity-filter').value,
source: document.getElementById('source-filter').value,
state: document.getElementById('state-filter').value,
search: document.getElementById('search-input').value.trim().toLowerCase()
};
}
function applyFilters() {
const f = getFilters();
const out = allEvents.filter(ev => {
const sev = String(ev.severity || '').toLowerCase();
const src = String(ev.source || '').toLowerCase();
const state = String(ev.state || '').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.source !== 'all' && src !== f.source) return false;
if (f.state !== 'all' && state !== f.state) return false;
if (f.search && !hay.includes(f.search)) return false;
return true;
});
renderTable(out);
}
function updateBanner(msg, show) {
const el = document.getElementById('license-warning');
if (!el) return;
el.textContent = msg || '';
el.classList.toggle('show', !!show);
}
async function fetchAlarms() {
const limit = document.getElementById('limit-select').value || '50';
document.getElementById('fetch-status').textContent = 'Status: loading...';
try {
const res = await fetch('/api/alarms?limit=' + encodeURIComponent(limit), { cache: 'no-store' });
if (res.status === 403) {
const data = await res.json().catch(() => ({}));
const message = data && data.error ? data.error : 'license required';
allEvents = [];
applyFilters();
updateBanner('Alarm API is locked: ' + message + '. Open /license to activate the app.', true);
document.getElementById('fetch-status').textContent = 'Status: license locked';
return;
}
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
allEvents = Array.isArray(data.events) ? data.events : [];
applyFilters();
updateBanner('', false);
document.getElementById('fetch-status').textContent = 'Status: OK';
document.getElementById('last-refresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString();
} catch (err) {
console.warn('Alarm fetch error:', err);
allEvents = [];
applyFilters();
updateBanner('Could not load alarms. Check app connectivity and browser console.', true);
document.getElementById('fetch-status').textContent = 'Status: error';
}
}
function exportCSV() {
const rows = [['time','severity','source','state','message','value','limit']];
const events = allEvents.filter(ev => {
const f = getFilters();
const sev = String(ev.severity || '').toLowerCase();
const src = String(ev.source || '').toLowerCase();
const state = String(ev.state || '').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.source !== 'all' && src !== f.source) return false;
if (f.state !== 'all' && state !== f.state) return false;
if (f.search && !hay.includes(f.search)) return false;
return true;
});
for (const ev of events) {
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 blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'force-monitor-alarms-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function syncAutoRefresh() {
const enabled = document.getElementById('auto-refresh').checked;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (enabled) refreshTimer = setInterval(fetchAlarms, 3000);
}
window.addEventListener('DOMContentLoaded', () => {
AppUI.initTheme();
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
document.getElementById('refresh-btn').addEventListener('click', fetchAlarms);
document.getElementById('export-btn').addEventListener('click', exportCSV);
document.getElementById('auto-refresh').addEventListener('change', syncAutoRefresh);
['severity-filter','source-filter','state-filter','search-input','limit-select'].forEach(id => {
document.getElementById(id).addEventListener(id === 'search-input' ? 'input' : 'change', () => {
if (id === 'limit-select') fetchAlarms(); else applyFilters();
});
});
fetchAlarms();
syncAutoRefresh();
});
</script>
</body>
</html>

176
static/app-common.js Normal file
View file

@ -0,0 +1,176 @@
(function(){
const THEME_KEY = 'force-monitor-theme';
const FULLSCREEN_INTENT_KEY = 'force-monitor-fullscreen-intent';
function byId(id){ return id ? document.getElementById(id) : null; }
function getFullscreenIntent(){
try { return sessionStorage.getItem(FULLSCREEN_INTENT_KEY) === '1'; } catch (_) { return false; }
}
function setFullscreenIntent(enabled){
try { sessionStorage.setItem(FULLSCREEN_INTENT_KEY, enabled ? '1' : '0'); } catch (_) {}
}
function setTheme(theme, opts){
opts = opts || {};
const t = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', t);
try { localStorage.setItem(THEME_KEY, t); } catch (_) {}
const btn = byId(opts.buttonId || 'theme-toggle');
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
if (typeof opts.onChange === 'function') opts.onChange(t);
return t;
}
function initTheme(opts){
opts = opts || {};
let theme = 'dark';
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'light' || stored === 'dark') theme = stored;
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) theme = 'light';
} catch (_) {}
setTheme(theme, opts);
const btn = byId(opts.buttonId || 'theme-toggle');
if (btn && !btn.dataset.themeBound) {
btn.dataset.themeBound = '1';
btn.addEventListener('click', function(){
setTheme(document.body.getAttribute('data-theme') === 'light' ? 'dark' : 'light', opts);
});
}
return theme;
}
function updateFullscreenButton(buttonId){
const btn = byId(buttonId || 'fullscreen-toggle');
if (!btn) return;
if (document.fullscreenElement) {
btn.textContent = 'Exit fullscreen';
return;
}
btn.textContent = getFullscreenIntent() ? 'Restore fullscreen' : 'Enter fullscreen';
}
async function requestFullscreenSafe(){
if (document.fullscreenElement) return true;
if (!document.fullscreenEnabled) return false;
try {
await document.documentElement.requestFullscreen();
setFullscreenIntent(true);
return true;
} catch (err) {
console.warn('Fullscreen restore/request blocked:', err);
return false;
}
}
async function toggleFullscreen(buttonId){
try {
if (!document.fullscreenElement) {
await requestFullscreenSafe();
} else {
setFullscreenIntent(false);
await document.exitFullscreen();
}
} catch (err) {
console.warn('Fullscreen error:', err);
} finally {
updateFullscreenButton(buttonId || 'fullscreen-toggle');
}
}
function bindFullscreenNavPersistence(){
if (document.documentElement.dataset.fsNavBound) return;
document.documentElement.dataset.fsNavBound = '1';
document.addEventListener('click', function(ev){
const link = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
if (!link) return;
const href = link.getAttribute('href') || '';
const target = link.getAttribute('target') || '';
if (!href || href.startsWith('#') || target === '_blank' || link.hasAttribute('download')) return;
try {
const url = new URL(link.href, window.location.href);
if (url.origin !== window.location.origin) return;
} catch (_) {
return;
}
if (document.fullscreenElement || getFullscreenIntent()) {
setFullscreenIntent(true);
}
}, true);
window.addEventListener('pageshow', function(){
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
updateFullscreenButton(el.id);
});
});
}
async function initFullscreen(opts){
opts = opts || {};
const buttonId = opts.buttonId || 'fullscreen-toggle';
const btn = byId(buttonId);
if (btn && !btn.dataset.fsBound) {
btn.dataset.fsBound = '1';
btn.addEventListener('click', function(){ toggleFullscreen(buttonId); });
}
if (!document.documentElement.dataset.fsListenerBound) {
document.documentElement.dataset.fsListenerBound = '1';
document.addEventListener('fullscreenchange', function(){
setFullscreenIntent(!!document.fullscreenElement);
document.querySelectorAll('#fullscreen-toggle, #fullscreen-btn').forEach(function(el){
updateFullscreenButton(el.id);
});
});
}
bindFullscreenNavPersistence();
updateFullscreenButton(buttonId);
if (getFullscreenIntent() && !document.fullscreenElement) {
// Best effort only: some browsers require a fresh user gesture after navigation.
requestAnimationFrame(function(){
requestFullscreenSafe().finally(function(){ updateFullscreenButton(buttonId); });
});
}
}
async function fetchJson(url, opts){
opts = opts || {};
const controller = new AbortController();
const timeoutMs = opts.timeoutMs || 8000;
const timer = setTimeout(function(){ controller.abort(); }, timeoutMs);
try {
const res = await fetch(url, {
method: opts.method || 'GET',
headers: opts.headers || undefined,
body: opts.body,
cache: 'no-store',
signal: controller.signal
});
let data = null;
try { data = await res.json(); } catch (_) { data = null; }
if (!res.ok) {
const err = new Error(data && data.error ? data.error : ('HTTP ' + res.status));
err.response = res;
err.data = data;
throw err;
}
return data;
} finally {
clearTimeout(timer);
}
}
window.AppUI = {
setTheme,
initTheme,
updateFullscreenButton,
toggleFullscreen,
initFullscreen,
fetchJson,
getFullscreenIntent,
setFullscreenIntent
};
})();

546
static/history.html Normal file
View file

@ -0,0 +1,546 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — History & Analytics</title>
<script src="/static/chart.umd.min.js"></script>
<style>
:root {
--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);
--tableHover:rgba(255,255,255,0.04); --good:#34d399; --warn:#facc15; --bad:#f87171; --info:#93c5fd;
}
body[data-theme="light"] {
--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);
--tableHover:rgba(15,23,42,0.04); --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,-apple-system,sans-serif;
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
body[data-theme="light"] {
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
.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); box-shadow:var(--shadow); }
.nav, .toolbar, .row { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.spacer { flex:1 1 auto; }
.btn, .input, select, .checkline {
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
padding:10px 14px; font:inherit;
}
.btn { cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
.btn:hover { transform:translateY(-1px); }
.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.warn { background:rgba(245,158,11,0.14); border-color:rgba(245,158,11,0.35); }
.btn.active { outline:2px solid rgba(14,165,233,0.32); }
.input { width:100%; }
.page-card { 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; font-size:14px; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.title { margin:8px 0 0 0; font-size:42px; line-height:1.02; }
.status-line { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:14px; margin-top:8px; }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
.card { padding:18px 20px; }
.metric-value { font-size:34px; font-weight:800; margin-top:8px; line-height:1; }
.metric-sub { color:var(--muted); margin-top:10px; font-size:14px; line-height:1.4; }
.ok { color:var(--good); }
.warning { color:var(--warn); }
.critical { color:var(--bad); }
.neutral { color:var(--muted); }
.chart-shell { padding:18px 20px 12px; }
.chart-box { height:58vh; min-height:460px; max-height:820px; }
.legend-checks { display:flex; flex-wrap:wrap; gap:10px; }
.checkline { display:inline-flex; align-items:center; gap:8px; min-height:auto; padding:9px 12px; }
.checkline input { accent-color:#38bdf8; }
.table-wrap { overflow:auto; }
table { width:100%; border-collapse:collapse; }
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; }
tbody tr:hover { background:var(--tableHover); }
.pill {
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;
}
.pill.good { background:rgba(16,185,129,0.12); color:var(--good); border-color:rgba(16,185,129,0.24); }
.pill.warn { background:rgba(245,158,11,0.12); color:var(--warn); border-color:rgba(245,158,11,0.24); }
.pill.bad { background:rgba(239,68,68,0.12); color:var(--bad); border-color:rgba(239,68,68,0.24); }
.pill.info { background:rgba(59,130,246,0.12); color:var(--info); border-color:rgba(59,130,246,0.24); }
.banner {
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;
}
.banner.show { display:block; }
.section-title { font-size:26px; margin:0; }
.note { color:var(--muted); font-size:13px; }
@media (max-width: 900px) {
.wrap { width:min(97vw, 1760px); padding:16px; }
.title { font-size:34px; }
.metric-value { font-size:28px; }
.chart-box { min-height:360px; height:46vh; }
}
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<a class="btn" href="/">Dashboard</a>
<a class="btn primary" href="/history">History</a>
<a class="btn" href="/alarms">Alarms</a>
<a class="btn" href="/kiosk">Kiosk</a>
<a class="btn" href="/process-capability">Process capability</a>
<a class="btn" href="/reports">Reports</a>
<a class="btn" href="/license">License</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</button>
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
</div>
<div class="glass page-card">
<div class="row">
<div>
<div class="kicker" id="page-kicker">Force Monitor</div>
<h1 class="title">Engineering History & Executive Analytics</h1>
<div class="sub">Longer-window peak force analytics, imbalance risk, percentile-based engineering metrics, alarm density, previous-window comparison, and top-event tables for engineering and management.</div>
</div>
<div class="spacer"></div>
<div class="status-line">
<span id="fetch-status">Status: idle</span>
<span id="last-refresh">Last refresh: --</span>
<span id="current-window">Window: --</span>
</div>
</div>
</div>
<div id="license-warning" class="banner"></div>
<div class="glass page-card">
<div class="toolbar" style="justify-content:space-between; align-items:flex-start;">
<div class="toolbar" style="align-items:center;">
<button class="btn active window-btn" data-window="15m">15m</button>
<button class="btn 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. 90m or 3h">
<button id="apply-window" class="btn primary" type="button">Apply</button>
</div>
<div class="toolbar">
<label class="checkline"><input id="auto-refresh" type="checkbox" checked> Auto refresh</label>
<button id="refresh-btn" class="btn good" type="button">Refresh now</button>
<button id="export-csv" class="btn warn" type="button">Export current CSV</button>
</div>
</div>
<div class="toolbar" style="margin-top:14px; justify-content:space-between; align-items:flex-start;">
<div class="legend-checks">
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="left" checked> Left %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="right" checked> Right %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="total" checked> Total %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="imbalance" checked> Imbalance %</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="warning" checked> Warning line</label>
<label class="checkline"><input class="series-toggle" type="checkbox" data-series="critical" checked> Critical line</label>
</div>
<div class="note" id="limit-note">Thresholds loading…</div>
</div>
</div>
<div class="grid cards" style="margin-bottom:18px;">
<div class="glass card"><div class="kicker">Avg total peak</div><div id="metric-avg-total" class="metric-value mono">--</div><div id="metric-avg-total-sub" class="metric-sub">Window average</div></div>
<div class="glass card"><div class="kicker">Max total peak</div><div id="metric-max-total" class="metric-value mono">--</div><div id="metric-max-total-sub" class="metric-sub">Highest total in window</div></div>
<div class="glass card"><div class="kicker">P95 / P99 total</div><div id="metric-p95-p99" class="metric-value mono">--</div><div id="metric-p95-p99-sub" class="metric-sub">Engineering percentiles</div></div>
<div class="glass card"><div class="kicker">Avg imbalance</div><div id="metric-avg-imb" class="metric-value mono">--</div><div id="metric-avg-imb-sub" class="metric-sub">Process centering quality</div></div>
<div class="glass card"><div class="kicker">Critical sample rate</div><div id="metric-critical-rate" class="metric-value mono">--</div><div id="metric-critical-rate-sub" class="metric-sub">% of samples in critical zone</div></div>
<div class="glass card"><div class="kicker">Alarm transitions</div><div id="metric-alarm-count" class="metric-value mono">--</div><div id="metric-alarm-count-sub" class="metric-sub">Window event density</div></div>
<div class="glass card"><div class="kicker">Vs previous window</div><div id="metric-prev-delta" class="metric-value mono">--</div><div id="metric-prev-delta-sub" class="metric-sub">Average total comparison</div></div>
<div class="glass card"><div class="kicker">Stability verdict</div><div id="metric-stability" class="metric-value mono">--</div><div id="metric-stability-sub" class="metric-sub">Analytics interpretation</div></div>
</div>
<div class="glass chart-shell" style="margin-bottom:18px;">
<div class="row" style="justify-content:space-between; margin-bottom:14px;">
<div>
<h2 class="section-title">Expanded Trend View</h2>
<div class="sub">Overlay left, right, total, and imbalance across the selected window. Imbalance uses the right axis so engineering can see centering drift without losing total-force detail.</div>
</div>
<div class="note">Refresh cadence follows the current page only. Open the dashboard for operator live view.</div>
</div>
<div class="chart-box"><canvas id="historyChart"></canvas></div>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); margin-bottom:18px;">
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:8px;">
<h2 class="section-title">Top total peaks</h2>
<span class="pill bad">stress points</span>
</div>
<div class="sub">Highest total peaks in the selected window. This helps engineering review overload clusters and lets management see the true worst-case demand.</div>
<div class="table-wrap" style="margin-top:14px;">
<table>
<thead><tr><th>Time</th><th class="right">Total %</th><th class="right">Total kN</th><th class="right">L %</th><th class="right">R %</th><th class="right">Imb %</th></tr></thead>
<tbody id="top-peaks-body"><tr><td colspan="6" class="note">No data</td></tr></tbody>
</table>
</div>
</div>
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:8px;">
<h2 class="section-title">Worst imbalances</h2>
<span class="pill warn">centering risk</span>
</div>
<div class="sub">Largest left-right differences in the selected window. This is ideal for die setup review, mechanical alignment checks, and boss-level trend summaries.</div>
<div class="table-wrap" style="margin-top:14px;">
<table>
<thead><tr><th>Time</th><th class="right">Imb %</th><th class="right">Total %</th><th class="right">L %</th><th class="right">R %</th><th class="right">Total kN</th></tr></thead>
<tbody id="worst-imb-body"><tr><td colspan="6" class="note">No data</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="glass page-card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<h2 class="section-title">Executive interpretation</h2>
<span id="boss-pill" class="pill info">loading</span>
</div>
<div id="boss-summary" class="sub">Loading analytics…</div>
</div>
</div>
<script src="/static/app-common.js"></script>
<script>
let config = { ui:{ title:'Force Monitor', unit_force:'kN' }, thresholds:{ warning_percent:80, critical_percent:95, gauge_max_percent:130, imbalance_warning_percent:10, imbalance_critical_percent:20 } };
let currentWindow = '15m';
let chart = null;
let historyData = [];
let analyticsData = null;
let trendData = null;
let refreshTimer = null;
let busy = false;
let currentTheme = 'dark';
const fmt1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 });
const fmt2 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 });
const percent = v => fmt1.format(Number(v || 0)) + '%';
const kn = v => fmt1.format(Number(v || 0)) + ' ' + ((config && config.ui && config.ui.unit_force) || 'kN');
const nowTime = () => new Date().toLocaleTimeString();
const escapeHtml = (value) => String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
function isLightTheme() { return currentTheme === 'light'; }
function qs(id) { return document.getElementById(id); }
function applyTheme(theme) {
currentTheme = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', currentTheme);
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (_) {}
qs('theme-toggle').textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
updateChartTheme();
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') theme = stored;
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) theme = 'light';
} catch (_) {}
applyTheme(theme);
}
function setFetchStatus(text) { qs('fetch-status').textContent = 'Status: ' + text; }
function setWarning(msg) {
const el = qs('license-warning');
if (!msg) { el.classList.remove('show'); el.textContent = ''; return; }
el.textContent = msg; el.classList.add('show');
}
async function fetchJson(url) {
const res = await fetch(url, { cache: 'no-store' });
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok) {
const msg = data && data.error ? data.error : ('HTTP ' + res.status);
const err = new Error(msg);
err.status = res.status;
err.payload = data;
throw err;
}
return data;
}
async function loadConfig() {
config = await fetchJson('/api/config/public');
document.title = ((config.ui && config.ui.title) || 'Force Monitor') + ' — History & Analytics';
qs('page-kicker').textContent = (config.ui && config.ui.title) || 'Force Monitor';
qs('limit-note').textContent = `Force W ${fmt1.format(config.thresholds.warning_percent)} / C ${fmt1.format(config.thresholds.critical_percent)} • Imbalance W ${fmt1.format(config.thresholds.imbalance_warning_percent)} / C ${fmt1.format(config.thresholds.imbalance_critical_percent)}`;
}
function buildChart() {
const ctx = qs('historyChart');
if (!ctx) return;
chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{ label:'Left %', data:[], borderColor:'#22d3ee', backgroundColor:'rgba(34,211,238,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Right %', data:[], borderColor:'#c084fc', backgroundColor:'rgba(192,132,252,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Total %', data:[], borderColor:'#34d399', backgroundColor:'rgba(52,211,153,0.10)', borderWidth:3, pointRadius:0, tension:0.18, yAxisID:'y' },
{ label:'Imbalance %', data:[], borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,0.10)', borderWidth:2, pointRadius:0, tension:0.18, yAxisID:'y1' },
{ label:'Warning limit', data:[], borderColor:'rgba(245,158,11,0.95)', borderWidth:2, pointRadius:0, borderDash:[8,6], tension:0, yAxisID:'y' },
{ label:'Critical limit', data:[], borderColor:'rgba(239,68,68,0.95)', borderWidth:2, pointRadius:0, borderDash:[8,6], tension:0, yAxisID:'y' }
]
},
options: {
responsive:true,
maintainAspectRatio:false,
interaction:{ mode:'index', intersect:false },
animation:false,
scales:{
x:{ grid:{ color:'rgba(255,255,255,0.06)' }, ticks:{ color:'#a1a1aa', maxTicksLimit:18 } },
y:{ beginAtZero:true, suggestedMax:130, grid:{ color:'rgba(255,255,255,0.06)' }, ticks:{ color:'#a1a1aa' }, title:{ display:true, text:'Force %' } },
y1:{ beginAtZero:true, suggestedMax:30, position:'right', grid:{ drawOnChartArea:false }, ticks:{ color:'#f59e0b' }, title:{ display:true, text:'Imbalance %' } }
},
plugins:{
legend:{ position:'top', labels:{ color:'#f4f4f5' } },
tooltip:{ backgroundColor:'rgba(9,9,11,0.96)', titleColor:'#f4f4f5', bodyColor:'#f4f4f5' }
}
}
});
updateChartTheme();
document.querySelectorAll('.series-toggle').forEach(input => input.addEventListener('change', syncSeriesVisibility));
}
function updateChartTheme() {
if (!chart) return;
const light = isLightTheme();
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
const tick = light ? '#334155' : '#a1a1aa';
const legend = light ? '#0f172a' : '#f4f4f5';
const tooltipBg = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.96)';
const tooltipText = light ? '#0f172a' : '#f4f4f5';
chart.options.scales.x.grid.color = grid;
chart.options.scales.x.ticks.color = tick;
chart.options.scales.y.grid.color = grid;
chart.options.scales.y.ticks.color = tick;
chart.options.scales.y1.ticks.color = light ? '#b45309' : '#f59e0b';
chart.options.plugins.legend.labels.color = legend;
chart.options.plugins.tooltip.backgroundColor = tooltipBg;
chart.options.plugins.tooltip.titleColor = tooltipText;
chart.options.plugins.tooltip.bodyColor = tooltipText;
chart.update('none');
}
function syncSeriesVisibility() {
if (!chart) return;
const mapping = { left:0, right:1, total:2, imbalance:3, warning:4, critical:5 };
document.querySelectorAll('.series-toggle').forEach(input => {
const idx = mapping[input.dataset.series];
if (typeof idx === 'number') chart.setDatasetVisibility(idx, input.checked);
});
chart.update('none');
}
function severityClass(rate) {
if (rate >= 10) return 'critical';
if (rate >= 2) return 'warning';
return 'ok';
}
function setMetric(id, value, sub, cls) {
const valueEl = qs(id);
const subEl = qs(id + '-sub');
if (!valueEl) return;
valueEl.className = 'metric-value mono ' + (cls || '');
valueEl.textContent = value;
if (subEl) subEl.textContent = sub || '';
}
function renderAnalytics() {
if (!analyticsData) return;
const a = analyticsData;
const t = trendData || {};
setMetric('metric-avg-total', percent(a.total_avg_pct), `Average total peak • ${kn(a.total_avg_kn)}`, a.total_avg_pct >= config.thresholds.warning_percent ? 'warning' : 'ok');
setMetric('metric-max-total', percent(a.total_max_pct), `Maximum total • ${kn(a.total_max_kn)}`, a.total_max_pct >= config.thresholds.critical_percent ? 'critical' : (a.total_max_pct >= config.thresholds.warning_percent ? 'warning' : 'ok'));
setMetric('metric-p95-p99', `${fmt1.format(a.total_p95_pct)} / ${fmt1.format(a.total_p99_pct)}`, 'P95 / P99 total %', a.total_p99_pct >= config.thresholds.critical_percent ? 'critical' : 'ok');
setMetric('metric-avg-imb', percent(a.imbalance_avg_pct), `P95 imbalance ${fmt1.format(a.imbalance_p95_pct)}% • Max ${fmt1.format(a.imbalance_max_pct)}%`, a.imbalance_avg_pct >= config.thresholds.imbalance_warning_percent ? 'warning' : 'ok');
setMetric('metric-critical-rate', fmt2.format(a.critical_rate_pct) + '%', `${a.critical_samples} critical samples • ${a.warning_samples} warning samples`, severityClass(a.critical_rate_pct));
setMetric('metric-alarm-count', String(a.alarm_transitions), `${a.critical_events} critical events • ${a.plc_disconnects} PLC disconnects`, a.critical_events > 0 ? 'warning' : 'ok');
const deltaCls = a.previous_window_delta_pct >= 5 ? 'critical' : (Math.abs(a.previous_window_delta_pct) >= 2 ? 'warning' : 'ok');
setMetric('metric-prev-delta', (a.previous_window_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_window_delta_pct) + '%', `Imbalance vs previous ${(a.previous_imbalance_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_imbalance_delta_pct)}%`, deltaCls);
const stability = String(t.process_stability || 'insufficient_data').toUpperCase();
const stabilityCls = t.process_stability === 'unstable' ? 'critical' : (t.process_stability === 'caution' ? 'warning' : 'ok');
setMetric('metric-stability', stability, t.stability_reason || 'No interpretation', stabilityCls);
qs('current-window').textContent = 'Window: ' + a.window;
renderTable('top-peaks-body', a.top_peaks || [], 'peaks');
renderTable('worst-imb-body', a.worst_imbalances || [], 'imb');
renderBossSummary();
}
function renderTable(targetId, rows, mode) {
const body = qs(targetId);
if (!body) return;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="6" class="note">No data in selected window</td></tr>';
return;
}
body.innerHTML = rows.map(row => {
if (mode === 'peaks') {
return `<tr><td class="mono">${escapeHtml(row.time)}</td><td class="right mono">${fmt1.format(row.total_percent)}%</td><td class="right mono">${fmt1.format(row.total_kn)}</td><td class="right mono">${fmt1.format(row.left_percent)}%</td><td class="right mono">${fmt1.format(row.right_percent)}%</td><td class="right mono">${fmt1.format(row.imbalance_percent)}%</td></tr>`;
}
return `<tr><td class="mono">${escapeHtml(row.time)}</td><td class="right mono">${fmt1.format(row.imbalance_percent)}%</td><td class="right mono">${fmt1.format(row.total_percent)}%</td><td class="right mono">${fmt1.format(row.left_percent)}%</td><td class="right mono">${fmt1.format(row.right_percent)}%</td><td class="right mono">${fmt1.format(row.total_kn)}</td></tr>`;
}).join('');
}
function renderBossSummary() {
if (!analyticsData) return;
const a = analyticsData;
const bossPill = qs('boss-pill');
const bossSummary = qs('boss-summary');
let headline = 'Stable';
let cls = 'good';
const parts = [];
if (a.total_max_pct >= config.thresholds.critical_percent || a.critical_events > 0 || a.plc_disconnects > 0) {
headline = 'Attention required'; cls = 'bad';
} else if (a.total_max_pct >= config.thresholds.warning_percent || a.imbalance_avg_pct >= config.thresholds.imbalance_warning_percent || Math.abs(a.previous_window_delta_pct) >= 2) {
headline = 'Watch closely'; cls = 'warn';
}
bossPill.className = 'pill ' + cls;
bossPill.textContent = headline;
parts.push(`In the selected ${a.window} window, the average total peak was ${fmt1.format(a.total_avg_pct)}% and the maximum reached ${fmt1.format(a.total_max_pct)}% (${kn(a.total_max_kn)}).`);
parts.push(`Critical-zone exposure was ${fmt2.format(a.critical_rate_pct)}% of samples, with ${a.critical_events} critical alarm transitions and ${a.plc_disconnects} PLC disconnect event(s).`);
parts.push(`Average imbalance was ${fmt1.format(a.imbalance_avg_pct)}%, worst imbalance was ${fmt1.format(a.imbalance_max_pct)}%, and the window-to-window change in average total was ${(a.previous_window_delta_pct >= 0 ? '+' : '') + fmt1.format(a.previous_window_delta_pct)}%.`);
if (trendData && trendData.process_stability) {
parts.push(`Trend interpretation reports ${String(trendData.process_stability).toUpperCase()} — ${trendData.stability_reason || 'no extra reason provided'}.`);
}
bossSummary.textContent = parts.join(' ');
}
function renderChart() {
if (!chart) return;
const labels = historyData.map(p => p.time);
const left = historyData.map(p => Number(p.sila_l || 0));
const right = historyData.map(p => Number(p.sila_r || 0));
const total = historyData.map(p => (Number(p.sila_l || 0) + Number(p.sila_r || 0)) / 2);
const imbalance = historyData.map(p => Math.abs(Number(p.sila_l || 0) - Number(p.sila_r || 0)));
const warning = labels.map(() => Number(config.thresholds.warning_percent || 0));
const critical = labels.map(() => Number(config.thresholds.critical_percent || 0));
chart.options.scales.y.suggestedMax = Math.max(Number(config.thresholds.gauge_max_percent || 130), 130);
chart.options.scales.y1.suggestedMax = Math.max(Number(config.thresholds.imbalance_critical_percent || 20) * 1.4, 30);
chart.data.labels = labels;
chart.data.datasets[0].data = left;
chart.data.datasets[1].data = right;
chart.data.datasets[2].data = total;
chart.data.datasets[3].data = imbalance;
chart.data.datasets[4].data = warning;
chart.data.datasets[5].data = critical;
chart.update('none');
syncSeriesVisibility();
}
async function refreshAll() {
if (busy) return;
busy = true;
setFetchStatus('loading…');
try {
const [history, analytics, trend] = await Promise.all([
fetchJson('/api/history?window=' + encodeURIComponent(currentWindow)),
fetchJson('/api/history/analytics?window=' + encodeURIComponent(currentWindow)),
fetchJson('/api/trend?window=' + encodeURIComponent(currentWindow))
]);
setWarning('');
historyData = Array.isArray(history.points) ? history.points : [];
analyticsData = analytics;
trendData = trend;
renderChart();
renderAnalytics();
setFetchStatus('ready');
qs('last-refresh').textContent = 'Last refresh: ' + nowTime();
} catch (err) {
console.warn('history page refresh failed', err);
if (err.status === 403) {
const msg = err.payload && err.payload.license && err.payload.license.message ? err.payload.license.message : err.message;
setWarning('License required or expired: ' + msg);
} else {
setWarning('Unable to load analytics: ' + err.message);
}
setFetchStatus('error');
} finally {
busy = false;
}
}
function setActiveWindowButtons() {
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.window === currentWindow));
}
function exportCsv() {
if (!historyData.length) return;
const rows = [['time','left_percent','right_percent','total_percent','imbalance_percent']];
for (const p of historyData) {
const left = Number(p.sila_l || 0);
const right = Number(p.sila_r || 0);
rows.push([p.time, left.toFixed(3), right.toFixed(3), ((left + right) / 2).toFixed(3), Math.abs(left - right).toFixed(3)]);
}
const csv = rows.map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type:'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `force-monitor-history-${currentWindow}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function scheduleRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
if (!qs('auto-refresh').checked) return;
refreshTimer = setInterval(refreshAll, 5000);
}
function wireEvents() {
AppUI.initTheme({ onChange: (t) => { currentTheme = t; updateChartTheme(); } });
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
AppUI.updateFullscreenButton('fullscreen-toggle');
qs('refresh-btn').addEventListener('click', refreshAll);
qs('export-csv').addEventListener('click', exportCsv);
qs('apply-window').addEventListener('click', () => {
const value = qs('custom-window').value.trim();
if (!value) return;
currentWindow = value;
setActiveWindowButtons();
refreshAll();
});
document.querySelectorAll('.window-btn').forEach(btn => btn.addEventListener('click', () => {
currentWindow = btn.dataset.window;
setActiveWindowButtons();
refreshAll();
}));
qs('auto-refresh').addEventListener('change', scheduleRefresh);
}
(async function init() {
// theme initialized by AppUI
wireEvents();
buildChart();
try { await loadConfig(); } catch (err) { setWarning('Failed to load public config: ' + err.message); }
setActiveWindowButtons();
await refreshAll();
scheduleRefresh();
})();
</script>
</body>
</html>

1417
static/index.html Normal file

File diff suppressed because it is too large Load diff

104
static/kiosk.html Normal file
View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — Kiosk</title>
<style>
.btn.primary { background:rgba(14,165,233,0.14); border-color:rgba(14,165,233,0.35); }
:root{--bg1:#030712;--bg2:#0f172a;--panel:rgba(255,255,255,.06);--border:rgba(255,255,255,.1);--text:#f8fafc;--muted:#94a3b8;--ok:#34d399;--warn:#facc15;--bad:#f87171;}
body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.10);--text:#0f172a;--muted:#475569;--ok:#059669;--warn:#b45309;--bad:#dc2626;}
*{box-sizing:border-box} body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 20% 10%, rgba(56,189,248,.14), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
body[data-theme="light"]{background:radial-gradient(circle at 20% 10%, rgba(14,165,233,.10), transparent 20%),radial-gradient(circle at 80% 10%, rgba(168,85,247,.10), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}
.wrap{width:min(96vw,1800px);margin:0 auto;padding:18px 22px 28px;} .row,.nav{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}
.btn{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:10px 14px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.05);color:var(--text);text-decoration:none;font-weight:600;cursor:pointer}
body[data-theme="light"] .btn{background:rgba(255,255,255,.88);}
.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
.hero{padding:18px 24px;margin-bottom:18px}.status{font-size:64px;font-weight:900;line-height:1;margin-top:12px}.sub{color:var(--muted)} .mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.card{padding:22px 24px}.label{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)} .value{font-size:54px;font-weight:900;line-height:1;margin-top:12px}
.small{font-size:18px;color:var(--muted);margin-top:10px}.banner{padding:16px 18px;border-radius:18px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.14);display:none;margin-bottom:16px}.banner.show{display:block}
.ok{color:var(--ok)} .warning{color:var(--warn)} .critical{color:var(--bad)} .neutral{color:var(--muted)}
.split{display:grid;grid-template-columns:1.35fr .85fr;gap:16px}.panel{padding:18px 22px} ul{margin:12px 0 0;padding-left:18px} li{margin:8px 0;color:var(--muted)}
@media (max-width:1200px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}.value{font-size:42px}.status{font-size:48px}}
@media (max-width:760px){.cards{grid-template-columns:1fr}.wrap{padding:14px}.value{font-size:36px}.status{font-size:38px}}
</style>
</head>
<body>
<div class="wrap">
<div class="nav" style="margin-bottom:14px">
<a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn 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="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-btn" class="btn" type="button">Enter fullscreen</button>
</div>
<div id="alarm-banner" class="banner"></div>
<div class="glass hero">
<div class="row"><div><div class="label" id="title-kicker">Force Monitor</div><div class="status" id="status-text">LOADING</div><div class="sub" id="status-reason">Preparing kiosk view…</div></div><div class="spacer"></div><div class="mono sub" id="clock">--</div></div>
</div>
<div class="grid cards">
<div class="glass card"><div class="label">Total peak</div><div id="total-value" class="value mono">--</div><div id="total-sub" class="small">kN / %</div></div>
<div class="glass card"><div class="label">Left</div><div id="left-value" class="value mono">--</div><div id="left-sub" class="small">kN / %</div></div>
<div class="glass card"><div class="label">Right</div><div id="right-value" class="value mono">--</div><div id="right-sub" class="small">kN / %</div></div>
<div class="glass card"><div class="label">Imbalance</div><div id="imb-value" class="value mono">--</div><div id="imb-sub" class="small">bias / trend</div></div>
</div>
<div class="split">
<div class="glass panel">
<div class="row"><h2 style="margin:0;font-size:30px">Live production verdict</h2><div class="spacer"></div><span id="stale-pill" class="mono sub">Data freshness: --</span></div>
<div id="verdict-summary" class="status neutral" style="font-size:56px;margin-top:14px">WAITING</div>
<div id="verdict-detail" class="sub" style="font-size:22px;margin-top:12px">No PLC data yet.</div>
<div class="row" style="margin-top:22px">
<div class="glass" style="padding:14px 16px;min-width:220px"><div class="label">Trend direction</div><div id="trend-force" class="value mono" style="font-size:32px">--</div><div id="trend-force-sub" class="small">force drift</div></div>
<div class="glass" style="padding:14px 16px;min-width:220px"><div class="label">Process stability</div><div id="trend-stability" class="value mono" style="font-size:32px">--</div><div id="trend-stability-sub" class="small">stability</div></div>
</div>
</div>
<div class="glass panel">
<div class="row"><h2 style="margin:0;font-size:28px">Active attention items</h2><div class="spacer"></div><span class="sub mono" id="last-refresh">Last refresh: --</span></div>
<ul id="attention-list"><li>Loading live status…</li></ul>
</div>
</div>
</div>
<script src="/static/app-common.js"></script>
<script>
let cfg={ui:{title:'Force Monitor',unit_force:'kN',unit_percent:'%'},thresholds:{warning_percent:80,critical_percent:95,imbalance_warning_percent:10,imbalance_critical_percent:20}};
const fmt=(n,d=1)=>Number(n||0).toFixed(d); const cls=(z)=>z==='critical'?'critical':z==='warning'?'warning':'ok';
function zone(v,w,c){return v>=c?'critical':v>=w?'warning':'ok'}
function setThemeTitle(){document.getElementById('title-kicker').textContent=cfg.ui.title+' • kiosk'}
async function loadCfg(){try{const r=await fetch('/api/config/public',{cache:'no-store'}); if(r.ok){cfg=await r.json(); setThemeTitle();}}catch(e){}}
async function refreshAll(){
try{
const [dataRes, trendRes, alarmsRes]=await Promise.all([
fetch('/api/data',{cache:'no-store'}), fetch('/api/trend?window=15m',{cache:'no-store'}), fetch('/api/alarms?limit=8',{cache:'no-store'})
]);
if(dataRes.status===403){document.getElementById('status-text').textContent='LICENSE REQUIRED';document.getElementById('status-text').className='status critical';document.getElementById('status-reason').textContent='Open /license to activate the application.';return;}
const d=await dataRes.json(); const t=trendRes.ok?await trendRes.json():{}; const a=alarmsRes.ok?await alarmsRes.json():{events:[]};
const connected=!!d.connected, stale=!!d.stale; const lp=Number(d.sila_l)||0, rp=Number(d.sila_r)||0, tp=Number(d.sum_percent)||0, tkn=Number(d.sum_kn)||0, imb=Number(d.imbalance_percent)||0, bias=Number(d.bias_percent)||0;
const lkn=Number(d.sila_l_kn)||0, rkn=Number(d.sila_r_kn)||0;
document.getElementById('clock').textContent=new Date().toLocaleString(); document.getElementById('last-refresh').textContent='Last refresh: '+new Date().toLocaleTimeString();
document.getElementById('total-value').textContent=fmt(tkn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('total-sub').textContent=fmt(tp,1)+(cfg.ui.unit_percent||'%')+' total load';
document.getElementById('left-value').textContent=fmt(lp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('left-sub').textContent=fmt(lkn,1)+' '+(cfg.ui.unit_force||'kN');
document.getElementById('right-value').textContent=fmt(rp,1)+(cfg.ui.unit_percent||'%'); document.getElementById('right-sub').textContent=fmt(rkn,1)+' '+(cfg.ui.unit_force||'kN');
document.getElementById('imb-value').textContent=fmt(imb,1)+(cfg.ui.unit_percent||'%'); document.getElementById('imb-sub').textContent='Bias '+fmt(bias,1)+(cfg.ui.unit_percent||'%');
const zForce=zone(Math.max(lp,rp),cfg.thresholds.warning_percent,cfg.thresholds.critical_percent); const zImb=zone(imb,cfg.thresholds.imbalance_warning_percent,cfg.thresholds.imbalance_critical_percent);
const statusEl=document.getElementById('status-text'); const reasonEl=document.getElementById('status-reason'); const verdict=document.getElementById('verdict-summary'); const detail=document.getElementById('verdict-detail');
let verdictText='OK', reason='Production looks stable.'; let level='ok';
if(!connected){ verdictText='PLC OFFLINE'; reason='No PLC communication.'; level='critical'; }
else if(stale){ verdictText='STALE DATA'; reason='PLC connected, but no fresh values are arriving.'; level='warning'; }
else if(zForce==='critical' || zImb==='critical'){ verdictText='CRITICAL'; reason='Force or imbalance reached critical region.'; level='critical'; }
else if(zForce==='warning' || zImb==='warning'){ verdictText='WARNING'; reason='Process is above warning thresholds.'; level='warning'; }
statusEl.textContent=verdictText; statusEl.className='status '+level; reasonEl.textContent=reason; verdict.textContent=verdictText; verdict.className='status '+level; detail.textContent=reason;
document.getElementById('stale-pill').textContent='Data freshness: '+(stale?'stale':connected?'fresh':'offline');
document.getElementById('trend-force').textContent=((Number(t.force_delta_pct)||0)>=0?'+':'')+fmt(t.force_delta_pct,1)+(cfg.ui.unit_percent||'%');
document.getElementById('trend-force').className='value mono '+(((Number(t.force_delta_pct)||0)>=3)?'warning':'ok');
document.getElementById('trend-force-sub').textContent=(t.force_direction||'--')+' over 15m';
document.getElementById('trend-stability').textContent=String(t.process_stability||'--').toUpperCase();
document.getElementById('trend-stability').className='value mono '+(t.process_stability==='unstable'?'critical':t.process_stability==='caution'?'warning':'ok');
document.getElementById('trend-stability-sub').textContent=t.stability_reason||'No trend reason';
const attention=[]; if(!connected) attention.push('Restore PLC communication to recover live monitoring.'); if(stale) attention.push('Investigate stale data path between PLC and the app.'); if(zForce!=='ok') attention.push('Force level is '+zForce+'; review current load and top-force causes.'); if(zImb!=='ok') attention.push('Imbalance is '+zImb+'; check centering, alignment, and tooling.');
(a.events||[]).slice(0,4).forEach(ev=>{if(ev.severity!=='info') attention.push((ev.time||'')+' • '+(ev.message||''));});
const ul=document.getElementById('attention-list'); ul.innerHTML=''; (attention.length?attention:['No active attention items.']).forEach(item=>{const li=document.createElement('li'); li.textContent=item; ul.appendChild(li);});
const banner=document.getElementById('alarm-banner'); if(level==='critical'){banner.textContent='Critical attention required — review force, imbalance, or PLC connectivity.'; banner.classList.add('show');} else if(level==='warning'){banner.textContent='Warning condition active — process should be reviewed.'; banner.classList.add('show');} else {banner.classList.remove('show');}
}catch(err){console.warn(err)}
}
AppUI.initTheme(); AppUI.initFullscreen({ buttonId:'fullscreen-btn' });
loadCfg().then(refreshAll); setInterval(refreshAll, 1500);
</script>
</body>
</html>

336
static/license.html Normal file
View file

@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Force Monitor — License</title>
<style>
:root {
--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);
--ok:#34d399; --warn:#facc15; --bad:#f87171; --info:#93c5fd;
}
body[data-theme="light"] {
--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);
--ok:#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,-apple-system,sans-serif;
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
body[data-theme="light"] {
background:
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%),
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
}
.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); }
.nav, .row, .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.nav { margin-bottom:18px; }
.btn, .input, textarea {
min-height:42px; border-radius:14px; border:1px solid var(--border); background:var(--btnbg); color:var(--text);
padding:10px 14px; font:inherit;
}
.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.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); }
.grid { display:grid; gap:16px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-bottom:18px; }
.card { padding:18px 20px; }
.kicker { font-size:11px; letter-spacing:.22em; color:var(--muted2); text-transform:uppercase; }
.value { font-size:30px; font-weight:800; margin-top:8px; }
.sub { color:var(--muted); margin-top:8px; font-size:14px; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
textarea { width:100%; min-height:210px; resize:vertical; }
.two { grid-template-columns:1.1fr .9fr; }
pre {
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;
}
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); }
.ok { color:var(--ok); }
.warnc { color:var(--warn); }
.bad { color:var(--bad); }
.info { color:var(--info); }
.field { margin-top:12px; }
.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; }
.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); }
.message { margin-top:12px; padding:14px 16px; border-radius:16px; display:none; }
.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.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.bad { color:#991b1b; }
.spacer { flex:1 1 auto; }
@media (max-width: 980px) { .two { grid-template-columns:1fr; } .wrap { width:min(96vw, 1560px); padding:16px; } }
</style>
</head>
<body data-theme="dark">
<div class="wrap">
<div class="nav">
<a class="btn" href="/">Dashboard</a>
<a class="btn" href="/history">History</a>
<a class="btn" href="/alarms">Alarms</a>
<a class="btn" href="/kiosk">Kiosk</a>
<a class="btn" href="/process-capability">Process capability</a>
<a class="btn" href="/reports">Reports</a>
<a class="btn primary" href="/license">License</a>
<div class="spacer"></div>
<button id="theme-toggle" class="btn" type="button">Light theme</button>
<button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button>
<button id="refresh-btn" class="btn warn" type="button">Refresh</button>
</div>
<div class="glass card" style="margin-bottom:18px;">
<div class="row">
<div>
<div class="kicker">Force Monitor</div>
<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>
<div class="spacer"></div>
<div class="row">
<span id="mode-badge" class="badge info">mode: loading</span>
<span id="lock-badge" class="badge">locked: --</span>
<span id="tamper-badge" class="badge">tamper: --</span>
</div>
</div>
<div id="action-message" class="message"></div>
</div>
<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">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">Fingerprint</div><div id="metric-fingerprint" class="value mono" style="font-size:24px;">--</div><div class="sub">Short fingerprint</div></div>
</div>
<div class="grid two">
<div class="glass card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<div>
<div class="kicker">Activation request</div>
<div class="sub">Send this JSON to your private signing tool or license issuer.</div>
</div>
<div class="toolbar">
<button id="copy-request-btn" class="btn" type="button">Copy</button>
<button id="download-request-btn" class="btn good" type="button">Download</button>
</div>
</div>
<pre id="request-json">Loading activation request...</pre>
</div>
<div class="glass card">
<div class="row" style="justify-content:space-between; margin-bottom:10px;">
<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>
<div class="toolbar">
<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>
</div>
</div>
<textarea id="license-text" class="mono" placeholder='{"app":"force_monitor",...}'></textarea>
</div>
</div>
<div class="glass card" style="margin-top:18px;">
<div class="kicker">Status details</div>
<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">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">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">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">Features</div><div id="detail-features">--</div></div>
</div>
</div>
</div>
<script src="/static/app-common.js"></script>
<script>
let activationRequestText = '';
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function setTheme(theme) {
const t = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', t);
try { localStorage.setItem('force-monitor-theme', t); } catch (e) {}
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = t === 'light' ? 'Dark theme' : 'Light theme';
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') theme = stored;
} catch (e) {}
setTheme(theme);
}
function setMessage(text, good) {
const box = document.getElementById('action-message');
if (!box) return;
box.textContent = text || '';
box.className = 'message' + (text ? ' show ' + (good ? 'good' : 'bad') : '');
}
function setDetail(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value || '--';
}
function setBadge(id, text, klass) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'badge ' + (klass || '');
el.textContent = text;
}
function renderStatus(status) {
const mode = String(status.mode || '--');
const locked = !!status.locked;
const tampered = !!status.tampered;
const activationConfigured = !!status.activation_configured;
const daysRemaining = Number.isFinite(Number(status.days_remaining)) ? String(status.days_remaining) : '--';
document.getElementById('metric-mode').textContent = mode.toUpperCase();
document.getElementById('metric-mode-sub').textContent = status.message || '--';
document.getElementById('metric-days').textContent = daysRemaining;
document.getElementById('metric-configured').textContent = activationConfigured ? 'YES' : 'NO';
document.getElementById('metric-fingerprint').textContent = status.fingerprint_short || '--';
setBadge('mode-badge', 'mode: ' + mode, locked ? 'bad' : (mode === 'licensed' ? 'ok' : 'info'));
setBadge('lock-badge', 'locked: ' + (locked ? 'yes' : 'no'), locked ? 'bad' : 'ok');
setBadge('tamper-badge', 'tamper: ' + (tampered ? 'yes' : 'no'), tampered ? 'bad' : 'ok');
setDetail('detail-message', status.message || '--');
setDetail('detail-customer', status.customer || '--');
setDetail('detail-license-id', status.license_id || '--');
setDetail('detail-hostname', status.hostname || '--');
setDetail('detail-fingerprint', status.fingerprint || '--');
const trialWindow = (status.trial_started_at || '--') + ' → ' + (status.trial_expires_at || '--');
setDetail('detail-trial-window', trialWindow);
setDetail('detail-expires', status.expires_at || '--');
setDetail('detail-features', Array.isArray(status.features) && status.features.length ? status.features.join(', ') : '--');
}
async function refreshStatus() {
try {
const status = await AppUI.fetchJson('/api/license/status', { timeoutMs:8000 });
renderStatus(status);
} catch (err) {
console.warn('License status error:', err);
setMessage('Could not load license status.', false);
document.getElementById('metric-mode').textContent = 'ERROR';
document.getElementById('metric-mode-sub').textContent = err && err.message ? err.message : 'Could not load license status.';
}
}
async function refreshRequest() {
try {
const data = await AppUI.fetchJson('/api/license/request', { timeoutMs:8000 });
activationRequestText = JSON.stringify(data, null, 2);
document.getElementById('request-json').textContent = activationRequestText;
} catch (err) {
console.warn('License request error:', err);
activationRequestText = '';
document.getElementById('request-json').textContent = 'Could not load activation request: ' + (err && err.message ? err.message : 'unknown error');
}
}
async function activateLicense() {
const text = document.getElementById('license-text').value.trim();
if (!text) {
setMessage('Paste a signed license JSON first.', false);
return;
}
setMessage('Activating license...', true);
try {
const res = await fetch('/api/license/activate', {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ license_text:text })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errMsg = data && data.error ? data.error : ('HTTP ' + res.status);
setMessage('Activation failed: ' + errMsg, false);
if (data && data.license) renderStatus(data.license);
return;
}
setMessage('License activated successfully.', true);
if (data && data.license) renderStatus(data.license);
await refreshStatus();
} catch (err) {
console.warn('License activate error:', err);
setMessage('Activation request failed.', false);
}
}
async function copyRequest() {
if (!activationRequestText) return;
try {
await navigator.clipboard.writeText(activationRequestText);
setMessage('Activation request copied to clipboard.', true);
} catch (err) {
setMessage('Clipboard copy failed.', false);
}
}
function downloadRequest() {
if (!activationRequestText) return;
const blob = new Blob([activationRequestText], { type:'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'force-monitor-activation-request.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function attachFileReader() {
const input = document.getElementById('license-file');
if (!input) return;
input.addEventListener('change', async () => {
const file = input.files && input.files[0];
if (!file) return;
const text = await file.text();
document.getElementById('license-text').value = text;
setMessage('Loaded license file into the text box.', true);
});
}
window.addEventListener('DOMContentLoaded', async () => {
AppUI.initTheme();
AppUI.initFullscreen({ buttonId:'fullscreen-toggle' });
document.getElementById('refresh-btn').addEventListener('click', async () => { setMessage('', true); await Promise.all([refreshStatus(), refreshRequest()]); });
document.getElementById('activate-btn').addEventListener('click', activateLicense);
document.getElementById('copy-request-btn').addEventListener('click', copyRequest);
document.getElementById('download-request-btn').addEventListener('click', downloadRequest);
attachFileReader();
await Promise.all([refreshStatus(), refreshRequest()]);
});
</script>
</body>
</html>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Force Monitor — Process Capability</title>
<script src="/static/chart.umd.min.js"></script>
<style>
:root{--bg1:#050816;--bg2:#0b1224;--panel:rgba(255,255,255,.055);--border:rgba(255,255,255,.1);--text:#f4f4f5;--muted:#a1a1aa;--muted2:#71717a;--btnbg:rgba(255,255,255,.05);--good:#34d399;--warn:#facc15;--bad:#f87171;--info:#93c5fd;}body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.1);--text:#0f172a;--muted:#475569;--muted2:#64748b;--btnbg:rgba(255,255,255,.88);--good:#059669;--warn:#b45309;--bad:#dc2626;--info:#1d4ed8;}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 10% 10%, rgba(34,211,238,.12), transparent 18%),radial-gradient(circle at 90% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}.wrap{width:min(95vw,1720px);margin:0 auto;padding:24px}.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}.nav,.row,.toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}.btn,.input{min-height:42px;border-radius:14px;border:1px solid var(--border);background:var(--btnbg);color:var(--text);padding:10px 14px;font:inherit}.btn{text-decoration:none;font-weight:600;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.btn.primary{background:rgba(14,165,233,.14);border-color:rgba(14,165,233,.35)}.page{padding:20px 22px;margin-bottom:18px}.kicker{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted2)}.sub{color:var(--muted);margin-top:8px}.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:18px}.card{padding:18px 20px}.value{font-size:34px;font-weight:800;margin-top:8px}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.chart-box{height:360px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left}th{font-size:12px;color:var(--muted2);text-transform:uppercase;letter-spacing:.16em}.pill{display:inline-flex;align-items:center;justify-content:center;min-width:88px;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid var(--border)}.good{color:var(--good)}.warning{color:var(--warn)}.critical{color:var(--bad)}@media(max-width:1080px){.chart-grid{grid-template-columns:1fr}.wrap{padding:16px}}</style></head>
<body data-theme="dark"><div class="wrap">
<div class="nav" style="margin-bottom:18px"><a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn primary" href="/process-capability">Process capability</a><a class="btn" href="/reports">Reports</a><a class="btn" href="/license">License</a><div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button></div>
<div class="glass page"><div class="row"><div><div class="kicker">Engineering capability</div><h1 style="margin:8px 0 0;font-size:42px;line-height:1.04">Process Capability & Distribution</h1><div class="sub">Histogram-based force and imbalance capability, one-sided CPU/CPK-style indicators against your configured thresholds, correlation between left and right columns, and suggested engineering action.</div></div><div class="spacer"></div><div class="mono sub" id="window-label">Window: --</div></div></div>
<div class="glass page"><div class="toolbar"><button class="btn primary window-btn" data-window="1h">1h</button><button class="btn window-btn" data-window="8h">8h</button><button class="btn window-btn" data-window="24h">24h</button><button class="btn window-btn" data-window="7d">7d</button><input id="custom-window" class="input" style="width:140px" placeholder="e.g. 3h or 2d"><button id="apply-window" class="btn" type="button">Apply</button><div class="spacer"></div><button id="refresh-btn" class="btn" type="button">Refresh</button></div></div>
<div class="grid cards"><div class="glass card"><div class="kicker">Total Cpk @ critical</div><div id="total-cpk-critical" class="value mono">--</div><div id="total-cpk-sub" class="sub">Capability versus critical load limit</div></div><div class="glass card"><div class="kicker">Imbalance Cpk @ critical</div><div id="imb-cpk-critical" class="value mono">--</div><div id="imb-cpk-sub" class="sub">Capability versus critical imbalance limit</div></div><div class="glass card"><div class="kicker">Left ↔ right correlation</div><div id="corr-value" class="value mono">--</div><div class="sub">Closer to 1.00 means both sides move together</div></div><div class="glass card"><div class="kicker">Suggested action</div><div id="action-pill" class="value" style="font-size:28px">--</div><div id="action-text" class="sub">Loading capability guidance…</div></div></div>
<div class="chart-grid"><div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Total force distribution</h2><div class="spacer"></div><span class="pill good">histogram</span></div><div class="sub">Distribution of total peak force against configured warning and critical boundaries.</div><div class="chart-box"><canvas id="totalHist"></canvas></div></div><div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Imbalance distribution</h2><div class="spacer"></div><span class="pill warning">histogram</span></div><div class="sub">Distribution of imbalance magnitude. A tight distribution below warning is usually what engineering wants.</div><div class="chart-box"><canvas id="imbHist"></canvas></div></div></div>
<div class="grid cards"><div class="glass card"><div class="kicker">Mean / σ total</div><div id="mean-total" class="value mono">--</div><div id="mean-total-sub" class="sub">P95 / P99 and warning occupancy</div></div><div class="glass card"><div class="kicker">Mean / σ imbalance</div><div id="mean-imb" class="value mono">--</div><div id="mean-imb-sub" class="sub">P95 and critical occupancy</div></div><div class="glass card"><div class="kicker">CPU warning / critical</div><div id="cpu-total" class="value mono">--</div><div class="sub">One-sided capability against upper limits</div></div><div class="glass card"><div class="kicker">Stability</div><div id="stability" class="value mono">--</div><div id="stability-sub" class="sub">Loading…</div></div></div>
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Top outliers</h2><div class="spacer"></div><span class="pill critical">review points</span></div><div class="sub">Combined overload and imbalance stress points worth engineering review.</div><div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Time</th><th>Total %</th><th>Total kN</th><th>L %</th><th>R %</th><th>Imb %</th></tr></thead><tbody id="outlier-body"><tr><td colspan="6">No data</td></tr></tbody></table></div></div>
</div>
<script src="/static/app-common.js"></script>
<script>
let currentWindow='1h', cfg={ui:{title:'Force Monitor',unit_force:'kN',unit_percent:'%'},thresholds:{warning_percent:80,critical_percent:95,imbalance_warning_percent:10,imbalance_critical_percent:20}}, totalChart=null, imbChart=null;
function fmt(n,d=2){return Number(n||0).toFixed(d)}
function updateChartTheme(){const light=document.body.dataset.theme==='light'; [totalChart,imbChart].forEach(ch=>{ if(!ch) return; ch.options.scales.x.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.y.ticks.color=light?'#334155':'#a1a1aa'; ch.options.scales.x.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.options.scales.y.grid.color=light?'rgba(15,23,42,.10)':'rgba(255,255,255,.06)'; ch.update('none');});}
function makeHistChart(id,label,color){return new Chart(document.getElementById(id),{type:'bar',data:{labels:[],datasets:[{label:label,borderColor:color,backgroundColor:color+'55',data:[]}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}}}}});}
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
async function refresh(){const r=await fetch('/api/process-capability?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); document.getElementById('window-label').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('total-cpk-critical').textContent=fmt(d.total_cpk_critical); document.getElementById('total-cpk-sub').textContent='Warning Cpk '+fmt(d.total_cpk_warning)+' • critical occupancy '+fmt(d.total_above_critical_pct,1)+'%'; document.getElementById('imb-cpk-critical').textContent=fmt(d.imbalance_cpk_critical); document.getElementById('imb-cpk-sub').textContent='Warning Cpk '+fmt(d.imbalance_cpk_warning)+' • critical occupancy '+fmt(d.imbalance_above_critical_pct,1)+'%'; document.getElementById('corr-value').textContent=fmt(d.left_right_correlation,3); document.getElementById('action-pill').textContent=(d.total_cpk_critical<1 || d.imbalance_cpk_critical<1)?'ACT':'OK'; document.getElementById('action-pill').className='value '+((d.total_cpk_critical<1 || d.imbalance_cpk_critical<1)?'critical':'good'); document.getElementById('action-text').textContent=d.suggested_action||'--'; document.getElementById('mean-total').textContent=fmt(d.total_mean_pct,1)+(cfg.ui.unit_percent||'%')+' / '+fmt(d.total_std_pct,2); document.getElementById('mean-total-sub').textContent='P95 '+fmt(d.total_p95_pct,1)+' P99 '+fmt(d.total_p99_pct,1)+' above warn '+fmt(d.total_above_warning_pct,1)+'%'; document.getElementById('mean-imb').textContent=fmt(d.imbalance_mean_pct,1)+(cfg.ui.unit_percent||'%')+' / '+fmt(d.imbalance_std_pct,2); document.getElementById('mean-imb-sub').textContent='P95 '+fmt(d.imbalance_p95_pct,1)+' above warn '+fmt(d.imbalance_above_warning_pct,1)+'%'; document.getElementById('cpu-total').textContent=fmt(d.total_cpu_warning)+' / '+fmt(d.total_cpu_critical); document.getElementById('stability').textContent=String(d.stability||'--').toUpperCase(); document.getElementById('stability').className='value mono '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); document.getElementById('stability-sub').textContent=d.stability_reason||'--';
if(!totalChart){ totalChart=makeHistChart('totalHist','Total %','#22d3ee'); imbChart=makeHistChart('imbHist','Imbalance %','#f59e0b'); updateChartTheme();}
totalChart.data.labels=(d.total_histogram||[]).map(b=>fmt(b.start,0)+'-'+fmt(b.end,0)); totalChart.data.datasets[0].data=(d.total_histogram||[]).map(b=>b.count); totalChart.update('none'); imbChart.data.labels=(d.imbalance_histogram||[]).map(b=>fmt(b.start,0)+'-'+fmt(b.end,0)); imbChart.data.datasets[0].data=(d.imbalance_histogram||[]).map(b=>b.count); imbChart.update('none');
const body=document.getElementById('outlier-body'); const rows=(d.top_outliers||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td></tr>').join(''); body.innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
AppUI.initTheme({ onChange: ()=>{ if(totalChart||imbChart) updateChartTheme(); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); loadCfg().then(()=>refresh().catch(console.warn));
</script></body></html>

25
static/reports.html Normal file
View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Force Monitor — Reports</title>
<script src="/static/chart.umd.min.js"></script>
<style>
:root{--bg1:#050816;--bg2:#0b1224;--panel:rgba(255,255,255,.055);--border:rgba(255,255,255,.1);--text:#f4f4f5;--muted:#a1a1aa;--muted2:#71717a;--btnbg:rgba(255,255,255,.05);--good:#34d399;--warn:#facc15;--bad:#f87171;--info:#93c5fd;}body[data-theme="light"]{--bg1:#eef4ff;--bg2:#f8fafc;--panel:rgba(255,255,255,.84);--border:rgba(15,23,42,.1);--text:#0f172a;--muted:#475569;--muted2:#64748b;--btnbg:rgba(255,255,255,.88);--good:#059669;--warn:#b45309;--bad:#dc2626;--info:#1d4ed8;}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;background:radial-gradient(circle at 10% 10%, rgba(34,211,238,.12), transparent 18%),radial-gradient(circle at 90% 10%, rgba(168,85,247,.14), transparent 18%),linear-gradient(180deg,var(--bg1),var(--bg2));}.wrap{width:min(95vw,1760px);margin:0 auto;padding:24px}.glass{background:var(--panel);border:1px solid var(--border);border-radius:24px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}.nav,.row,.toolbar{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.spacer{flex:1 1 auto}.btn,.input{min-height:42px;border-radius:14px;border:1px solid var(--border);background:var(--btnbg);color:var(--text);padding:10px 14px;font:inherit}.btn{text-decoration:none;font-weight:600;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.btn.primary{background:rgba(14,165,233,.14);border-color:rgba(14,165,233,.35)}.page{padding:20px 22px;margin-bottom:18px}.kicker{font-size:11px;letter-spacing:.22em;color:var(--muted2);text-transform:uppercase}.sub{color:var(--muted);margin-top:8px}.grid{display:grid;gap:16px}.cards{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin-bottom:18px}.card{padding:18px 20px}.value{font-size:34px;font-weight:800;margin-top:8px}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.chart-box{height:420px}.list{margin:10px 0 0;padding-left:18px}.list li{margin:8px 0;color:var(--muted)}.pill{display:inline-flex;align-items:center;justify-content:center;min-width:88px;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid var(--border)}.good{color:var(--good)}.warning{color:var(--warn)}.critical{color:var(--bad)}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:12px 10px;border-bottom:1px solid var(--border);text-align:left}th{font-size:12px;color:var(--muted2);text-transform:uppercase;letter-spacing:.16em}@media(max-width:1080px){.wrap{padding:16px}}</style></head>
<body data-theme="dark"><div class="wrap">
<div class="nav" style="margin-bottom:18px"><a class="btn" href="/">Dashboard</a><a class="btn" href="/history">History</a><a class="btn" href="/alarms">Alarms</a><a class="btn" href="/kiosk">Kiosk</a><a class="btn" href="/process-capability">Process capability</a><a class="btn primary" href="/reports">Reports</a><a class="btn" href="/license">License</a><div class="spacer"></div><button id="theme-toggle" class="btn" type="button">Light theme</button><button id="fullscreen-toggle" class="btn" type="button">Enter fullscreen</button></div>
<div class="glass page"><div class="row"><div><div class="kicker">Management & engineering report</div><h1 style="margin:8px 0 0;font-size:42px;line-height:1.04">Shift, Day & Week Reports</h1><div class="sub">A report-friendly view for engineering and boss departments with health score, availability estimate, event counts, peak summaries, trend deltas, and a bucket chart for the selected period.</div></div><div class="spacer"></div><div class="mono sub" id="report-range">Window: --</div></div></div>
<div class="glass page"><div class="toolbar"><button class="btn primary window-btn" data-window="8h">Shift (8h)</button><button class="btn window-btn" data-window="24h">Day</button><button class="btn window-btn" data-window="7d">Week</button><button class="btn window-btn" data-window="30d">Month</button><input id="custom-window" class="input" style="width:160px" placeholder="e.g. 48h or 14d"><button id="apply-window" class="btn" type="button">Apply</button><div class="spacer"></div><button id="refresh-btn" class="btn" type="button">Refresh</button><button id="download-json" class="btn" type="button">Download JSON</button></div></div>
<div class="grid cards"><div class="glass card"><div class="kicker">Health score</div><div id="health" class="value mono">--</div><div id="health-sub" class="sub">Availability and event pressure</div></div><div class="glass card"><div class="kicker">Avg / peak total</div><div id="avg-peak" class="value mono">--</div><div id="avg-peak-sub" class="sub">Total force summary</div></div><div class="glass card"><div class="kicker">Avg / peak imbalance</div><div id="avg-imb" class="value mono">--</div><div id="avg-imb-sub" class="sub">Centering summary</div></div><div class="glass card"><div class="kicker">Events</div><div id="events" class="value mono">--</div><div id="events-sub" class="sub">Warnings, criticals, PLC disconnects</div></div></div>
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Executive summary</h2><div class="spacer"></div><span id="summary-pill" class="pill good">loading</span></div><div id="executive-summary" class="sub" style="font-size:18px;margin-top:14px">Loading report…</div><ul id="findings" class="list"><li>Loading findings…</li></ul></div>
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Bucket trend</h2><div class="spacer"></div><span class="pill">selected period</span></div><div class="sub">Each bucket summarizes average total force, maximum force, and event density inside the selected report window.</div><div class="chart-box"><canvas id="reportChart"></canvas></div></div>
<div class="glass page"><div class="row"><h2 style="margin:0;font-size:28px">Top peaks in report window</h2><div class="spacer"></div><span class="pill critical">top load moments</span></div><div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Time</th><th>Total %</th><th>Total kN</th><th>Imb %</th><th>L %</th><th>R %</th></tr></thead><tbody id="top-peaks-body"><tr><td colspan="6">No data</td></tr></tbody></table></div></div>
</div>
<script src="/static/app-common.js"></script>
<script>
let currentWindow='8h', reportCache=null, cfg={ui:{unit_force:'kN',unit_percent:'%'}}, chart=null;
function fmt(n,d=1){return Number(n||0).toFixed(d)} function setTheme(theme){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');}}
async function loadCfg(){try{cfg=await AppUI.fetchJson('/api/config/public',{timeoutMs:8000});}catch(e){console.warn('Config load error:',e)}}
function makeChart(){chart=new Chart(document.getElementById('reportChart'),{type:'bar',data:{labels:[],datasets:[{type:'bar',label:'Avg total %',backgroundColor:'rgba(34,211,238,.55)',borderColor:'#22d3ee',data:[]},{type:'line',label:'Max total %',borderColor:'#f87171',backgroundColor:'rgba(248,113,113,.12)',tension:.18,borderWidth:3,data:[],yAxisID:'y'},{type:'line',label:'Warning+Critical events',borderColor:'#facc15',backgroundColor:'rgba(250,204,21,.10)',tension:.18,borderWidth:3,data:[],yAxisID:'y1'}]},options:{responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#f4f4f5'}}},scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y:{grid:{color:'rgba(255,255,255,.06)'},ticks:{color:'#a1a1aa'}},y1:{position:'right',grid:{color:'transparent'},ticks:{color:'#a1a1aa'}}}}}); setTheme(document.body.dataset.theme||'dark');}
async function refresh(){const r=await fetch('/api/reports/summary?window='+encodeURIComponent(currentWindow),{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); const d=await r.json(); reportCache=d; document.getElementById('report-range').textContent='Window: '+d.window+' • '+d.from+' → '+d.to; document.getElementById('health').textContent=d.health_score+'/100'; document.getElementById('health').className='value mono '+(d.health_score<70?'critical':d.health_score<85?'warning':'good'); document.getElementById('health-sub').textContent='Availability '+fmt(d.availability_pct,1)+'% stability '+String(d.stability||'--').toUpperCase(); document.getElementById('avg-peak').textContent=fmt(d.average_total_pct,1)+' / '+fmt(d.peak_total_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-peak-sub').textContent='Avg '+fmt(d.average_total_kn,1)+' '+(cfg.ui.unit_force||'kN')+' peak '+fmt(d.peak_total_kn,1)+' '+(cfg.ui.unit_force||'kN'); document.getElementById('avg-imb').textContent=fmt(d.average_imbalance_pct,1)+' / '+fmt(d.peak_imbalance_pct,1)+(cfg.ui.unit_percent||'%'); document.getElementById('avg-imb-sub').textContent='Δ force '+((d.force_delta_pct>=0)?'+':'')+fmt(d.force_delta_pct,1)+' • Δ imb '+((d.imbalance_delta_pct>=0)?'+':'')+fmt(d.imbalance_delta_pct,1); document.getElementById('events').textContent=d.warning_events+' / '+d.critical_events; document.getElementById('events-sub').textContent='Warnings / criticals • PLC disconnects '+d.plc_disconnects; document.getElementById('executive-summary').textContent=d.executive_summary||'--'; document.getElementById('summary-pill').textContent=String(d.stability||'stable').toUpperCase(); document.getElementById('summary-pill').className='pill '+(d.stability==='unstable'?'critical':d.stability==='caution'?'warning':'good'); const findings=document.getElementById('findings'); findings.innerHTML=''; (d.findings||[]).forEach(item=>{const li=document.createElement('li'); li.textContent=item; findings.appendChild(li);}); if(!chart) makeChart(); chart.data.labels=(d.buckets||[]).map(b=>b.label); chart.data.datasets[0].data=(d.buckets||[]).map(b=>b.avg_total_pct); chart.data.datasets[1].data=(d.buckets||[]).map(b=>b.max_total_pct); chart.data.datasets[2].data=(d.buckets||[]).map(b=>(b.warning_events||0)+(b.critical_events||0)); chart.update('none'); const rows=(d.top_peaks||[]).map(p=>'<tr><td>'+p.time+'</td><td>'+fmt(p.total_percent,1)+'</td><td>'+fmt(p.total_kn,1)+'</td><td>'+fmt(p.imbalance_percent,1)+'</td><td>'+fmt(p.left_percent,1)+'</td><td>'+fmt(p.right_percent,1)+'</td></tr>').join(''); document.getElementById('top-peaks-body').innerHTML=rows||'<tr><td colspan="6">No data</td></tr>';}
function useWindow(v){currentWindow=v; document.querySelectorAll('.window-btn').forEach(btn=>btn.classList.toggle('primary',btn.dataset.window===v)); refresh().catch(console.warn)}
AppUI.initTheme({ onChange: ()=>{ if(chart) setTheme(document.body.dataset.theme || 'dark'); } }); AppUI.initFullscreen({ buttonId:'fullscreen-toggle' }); document.getElementById('refresh-btn').addEventListener('click',()=>refresh().catch(console.warn)); document.getElementById('apply-window').addEventListener('click',()=>{const v=document.getElementById('custom-window').value.trim(); if(v) useWindow(v)}); document.querySelectorAll('.window-btn').forEach(btn=>btn.addEventListener('click',()=>useWindow(btn.dataset.window))); document.getElementById('download-json').addEventListener('click',()=>{ if(!reportCache) return; const blob=new Blob([JSON.stringify(reportCache,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='force-monitor-report-'+currentWindow+'.json'; a.click(); URL.revokeObjectURL(a.href);}); loadCfg().then(()=>refresh().catch(console.warn));
</script></body></html>