Compare commits

...

19 commits
1.0.3 ... 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
16 changed files with 4348 additions and 1548 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"]

View file

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

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

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

View file

@ -32,6 +32,21 @@ type LicenseConfig struct {
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"`
@ -227,7 +242,7 @@ func (m *LicenseManager) ActivateFromText(text string) error {
return errors.New("licensing disabled")
}
if len(m.publicKey) != ed25519.PublicKeySize {
return errors.New("no license public key configured; set license.public_key_base64 first")
return errors.New("no license public key configured; set the embedded verifier public key")
}
text = strings.TrimSpace(text)
@ -300,6 +315,7 @@ func (m *LicenseManager) loadExistingLicense() error {
return fmt.Errorf("parse existing license: %w", err)
}
if err := m.validateLicenseLocked(lic); err != nil {
m.active = nil
return nil
}
m.active = &lic
@ -615,23 +631,8 @@ func MarshalLicensePayloadForSigning(lic SignedLicense) ([]byte, error) {
return json.Marshal(payload)
}
// Helper for a future private signing tool.
func SignLicenseWithPrivateKey(lic SignedLicense, privateKeyBase64 string) (SignedLicense, error) {
privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64))
if err != nil {
return lic, fmt.Errorf("decode private key: %w", err)
}
if len(privRaw) != ed25519.PrivateKeySize {
return lic, fmt.Errorf("invalid private key size")
}
payloadBytes, err := MarshalLicensePayloadForSigning(lic)
if err != nil {
return lic, err
}
sig := ed25519.Sign(ed25519.PrivateKey(privRaw), payloadBytes)
lic.Signature = base64.StdEncoding.EncodeToString(sig)
return lic, nil
}
// 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.

1171
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>

View file

@ -15,6 +15,8 @@
--button-bg: rgba(255,255,255,0.05);
--button-border: rgba(255,255,255,0.10);
--button-text: #e4e4e7;
--text: var(--body-text);
--border: var(--button-border);
}
* { box-sizing: border-box; }
@ -38,6 +40,8 @@
--button-bg: rgba(255,255,255,0.88);
--button-border: rgba(15,23,42,0.10);
--button-text: #0f172a;
--text: var(--body-text);
--border: var(--button-border);
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%),
@ -84,22 +88,30 @@
body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }
.control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
min-height: 42px;
padding: 10px 14px;
border-radius: 16px;
border: 1px solid var(--button-border);
background: var(--button-bg);
color: var(--button-text);
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(255,255,255,.05);
color: var(--text);
text-decoration: none;
font-weight: 600;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
cursor: pointer;
transition: 160ms ease;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
body[data-theme="light"] .control-btn {
background: rgba(255,255,255,.88);
}
.control-btn.primary {
background: rgba(14,165,233,0.14);
border-color: rgba(14,165,233,0.35);
}
.control-btn:hover {
@ -278,10 +290,23 @@
transition: opacity 180ms ease, filter 180ms ease;
}
</style>
<base target="_blank">
</head>
<body data-theme="dark">
<div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
<div class="w-[95vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
<div class="glass border border-white/10 rounded-3xl p-4 mb-8">
<div class="flex flex-wrap gap-3 items-center justify-between">
<div class="flex flex-wrap gap-3">
<a href="/" class="control-btn primary" target="_self">Dashboard</a>
<a href="/history" class="control-btn" target="_self">History</a>
<a href="/alarms" class="control-btn" target="_self">Alarms</a>
<a href="/kiosk" class="control-btn" target="_self">Kiosk</a>
<a href="/process-capability" class="control-btn" target="_self">Process Capability</a>
<a href="/reports" class="control-btn" target="_self">Reports</a>
<a href="/license" class="control-btn" target="_self">License</a>
</div>
<div class="text-zinc-500 text-sm font-mono">Embedded UI navigation</div>
</div>
</div>
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
<div class="flex items-center gap-3">
<span class="text-2xl">&#9888;&#65039;</span>
@ -317,6 +342,9 @@
</div>
</div>
<div id="process-content">
{{if .ShowVerdict}}
<div id="verdict-card" class="verdict-card neutral mb-8">
@ -573,7 +601,8 @@
</div><!-- #process-content -->
</div>
<script>
<script src="/static/app-common.js"></script>
<script>
const WARNING_PERCENT = {{.WarningPercent}};
const CRITICAL_PERCENT = {{.CriticalPercent}};
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
@ -810,7 +839,6 @@
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 58);
}
// FIX: update digital display HTML elements (only visible when show_gauge_digital: true)
function updateDigitalDisplay(side, percent, kn) {
if (!SHOW_GAUGE_DIGITAL) return;
const el = document.getElementById('digital-' + side);
@ -1054,54 +1082,6 @@
return n.toFixed(1) + UNIT_PCT;
}
function applyTheme(theme) {
currentTheme = theme === 'light' ? 'light' : 'dark';
document.body.setAttribute('data-theme', currentTheme);
try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (e) {}
updateThemeButton();
updateChartTheme();
redrawGauges();
}
function initTheme() {
let theme = 'dark';
try {
const stored = localStorage.getItem('force-monitor-theme');
if (stored === 'light' || stored === 'dark') {
theme = stored;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
theme = 'light';
}
} catch (e) {}
applyTheme(theme);
}
function toggleTheme() { applyTheme(isLightTheme() ? 'dark' : 'light'); }
function updateThemeButton() {
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
}
function updateFullscreenButton() {
const btn = document.getElementById('fullscreen-toggle');
if (btn) btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
}
async function toggleFullscreen() {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (err) {
console.warn('Fullscreen error:', err);
} finally {
updateFullscreenButton();
}
}
function updateChartTheme() {
if (!SHOW_TREND_CHART || !lineChart) return;
const light = isLightTheme();
@ -1132,6 +1112,20 @@
} catch (err) { console.warn('UI revision check error:', err); }
}
function computeStaleFromPayload(d, connected) {
if (!connected) return false;
if (typeof d.stale === 'boolean') return d.stale;
if (!d.last_update) return false;
const lastTs = new Date(d.last_update).getTime();
if (isNaN(lastTs)) return false;
const serverNowTs = d.server_time ? new Date(d.server_time).getTime() : Date.now();
if (isNaN(serverNowTs)) return false;
return (serverNowTs - lastTs) > STALE_MS;
}
async function fetchLiveData() {
try {
const res = await fetch('/api/data', { cache: 'no-store' });
@ -1148,12 +1142,7 @@
const bias = Number(d.bias_percent) || 0;
const leftKN = Number(d.sila_l_kn) || 0;
const rightKN = Number(d.sila_r_kn) || 0;
let stale = false;
if (connected && d.last_update) {
const lastTs = new Date(d.last_update).getTime();
if (!isNaN(lastTs)) stale = (Date.now() - lastTs) > STALE_MS;
}
const stale = computeStaleFromPayload(d, connected);
setConnectionIndicator(connected, stale);
setProcessVisualState(connected && !stale);
@ -1171,8 +1160,6 @@
applyChannelState('l', leftPercent);
applyChannelState('r', rightPercent);
// FIX: update digital HTML displays (was missing in original)
updateDigitalDisplay('l', leftPercent, leftKN);
updateDigitalDisplay('r', rightPercent, rightKN);
@ -1188,7 +1175,6 @@
updateSummaryBar(false, false, 0, 0, 0);
updateMachineVerdict(false, false, 0, 0, 0);
updateAlarmBanner(0, 0, 0, false, false);
// FIX: reset digital displays on error
updateDigitalDisplay('l', 0, 0);
updateDigitalDisplay('r', 0, 0);
}
@ -1331,17 +1317,21 @@
}
window.onload = () => {
initTheme();
AppUI.initTheme({
buttonId: 'theme-toggle',
onChange: (theme) => {
currentTheme = theme;
updateChartTheme();
redrawGauges();
}
});
if (SHOW_HEADER_CONTROLS) {
AppUI.initFullscreen({ buttonId: 'fullscreen-toggle' });
}
setActiveWindowButton(DEFAULT_WINDOW);
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
if (SHOW_HEADER_CONTROLS) {
const themeBtn = document.getElementById('theme-toggle');
const fsBtn = document.getElementById('fullscreen-toggle');
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
}
document.querySelectorAll('.window-btn').forEach(btn =>
btn.addEventListener('click', () => useWindow(btn.dataset.window)));
@ -1372,8 +1362,6 @@
});
}
document.addEventListener('fullscreenchange', updateFullscreenButton);
updateFullscreenButton();
if (SHOW_TREND_CHART) {
const chartCanvas = document.getElementById('lineChart');

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>