added modules; user can disable, enable them

This commit is contained in:
Gamer 2026-04-17 19:32:33 +02:00
parent 8cd6e066e8
commit b1d69f1697

408
main.go
View file

@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"html/template"
"io/fs"
"log"
"math"
"net/http"
@ -29,7 +30,7 @@ import (
//go:embed static
var staticFiles embed.FS
const version = "0.7.1"
const version = "0.8.0"
// ---------------------------------------------------------------------------
// Config structs
@ -42,6 +43,7 @@ type Config struct {
Trend TrendConfig `yaml:"trend"`
Press PressConfig `yaml:"press"`
UI UIConfig `yaml:"ui"`
Modules ModulesConfig `yaml:"modules"`
DB DBConfig `yaml:"db"`
}
@ -76,9 +78,6 @@ type TrendConfig struct {
Minutes int `yaml:"minutes"`
}
// PressConfig: Go field is MaxTonnage (idiomatic). YAML tag kept as MAX_TONNAGE
// so existing config files need no changes. LegacyMaxTonnage handles old
// configs that used the lowercase "max_tonnage" key.
type PressConfig struct {
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
@ -93,6 +92,17 @@ type UIConfig struct {
UnitPct string `yaml:"unit_percent"`
}
type ModulesConfig struct {
ShowHeaderControls *bool `yaml:"show_header_controls,omitempty"`
ShowVerdict *bool `yaml:"show_verdict,omitempty"`
ShowSummaryBar *bool `yaml:"show_summary_bar,omitempty"`
ShowOverview *bool `yaml:"show_overview,omitempty"`
ShowIntelligence *bool `yaml:"show_intelligence,omitempty"`
ShowAlarmTimeline *bool `yaml:"show_alarm_timeline,omitempty"`
ShowGauges *bool `yaml:"show_gauges,omitempty"`
ShowTrendChart *bool `yaml:"show_trend_chart,omitempty"`
}
type DBConfig struct {
Path string `yaml:"path"`
BusyTimeoutMs int `yaml:"busy_timeout_ms"`
@ -106,6 +116,17 @@ type DBConfig struct {
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
}
func boolPtr(v bool) *bool {
return &v
}
func boolValue(v *bool, def bool) bool {
if v == nil {
return def
}
return *v
}
func defaultConfig() Config {
return Config{
Server: ServerConfig{
@ -142,6 +163,16 @@ func defaultConfig() Config {
UnitForce: "kN",
UnitPct: "%",
},
Modules: ModulesConfig{
ShowHeaderControls: boolPtr(true),
ShowVerdict: boolPtr(true),
ShowSummaryBar: boolPtr(true),
ShowOverview: boolPtr(true),
ShowIntelligence: boolPtr(true),
ShowAlarmTimeline: boolPtr(true),
ShowGauges: boolPtr(true),
ShowTrendChart: boolPtr(true),
},
DB: DBConfig{
Path: "force_monitor.db",
BusyTimeoutMs: 5000,
@ -158,7 +189,7 @@ func defaultConfig() Config {
}
// ---------------------------------------------------------------------------
// Config normalisation helpers — eliminate repetitive if-chains
// Config normalisation helpers
// ---------------------------------------------------------------------------
func setIfZeroF(dst *float64, def float64) {
@ -179,6 +210,13 @@ func setIfEmpty(dst *string, def string) {
}
}
func setIfNilBool(dst **bool, def bool) {
if *dst == nil {
v := def
*dst = &v
}
}
func normalizeConfig(cfg *Config) {
def := defaultConfig()
@ -186,12 +224,13 @@ func normalizeConfig(cfg *Config) {
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
setIfZeroI(&cfg.PLC.Rack, def.PLC.Rack)
setIfZeroI(&cfg.PLC.Slot, def.PLC.Slot)
setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)
// Legacy threshold key migration (warning_kn / critical_kn / max_kn)
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
}
@ -214,7 +253,6 @@ func normalizeConfig(cfg *Config) {
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
// Legacy press tonnage key migration (max_tonnage lowercase)
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
}
@ -227,6 +265,15 @@ func normalizeConfig(cfg *Config) {
setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)
setIfNilBool(&cfg.Modules.ShowHeaderControls, boolValue(def.Modules.ShowHeaderControls, true))
setIfNilBool(&cfg.Modules.ShowVerdict, boolValue(def.Modules.ShowVerdict, true))
setIfNilBool(&cfg.Modules.ShowSummaryBar, boolValue(def.Modules.ShowSummaryBar, true))
setIfNilBool(&cfg.Modules.ShowOverview, boolValue(def.Modules.ShowOverview, true))
setIfNilBool(&cfg.Modules.ShowIntelligence, boolValue(def.Modules.ShowIntelligence, true))
setIfNilBool(&cfg.Modules.ShowAlarmTimeline, boolValue(def.Modules.ShowAlarmTimeline, true))
setIfNilBool(&cfg.Modules.ShowGauges, boolValue(def.Modules.ShowGauges, true))
setIfNilBool(&cfg.Modules.ShowTrendChart, boolValue(def.Modules.ShowTrendChart, true))
setIfEmpty(&cfg.DB.Path, def.DB.Path)
setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
@ -388,6 +435,15 @@ type PageData struct {
PollMs int
DefaultWindow string
DefaultTrendWindow string
ShowHeaderControls bool
ShowVerdict bool
ShowSummaryBar bool
ShowOverview bool
ShowIntelligence bool
ShowAlarmTimeline bool
ShowGauges bool
ShowTrendChart bool
}
type NumericStats struct {
@ -430,11 +486,11 @@ var (
alarmCh chan AlarmEvent
alarmTracker AlarmTracker
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
cachedUI []byte // pre-rendered template (PageData is immutable after startup)
cachedUI []byte
)
// ---------------------------------------------------------------------------
// Force calculation — accepts maxTonnage explicitly (testable, no global dep)
// Force calculation
// ---------------------------------------------------------------------------
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
@ -509,9 +565,6 @@ func enqueueAlarm(a AlarmEvent) {
// Database initialisation
// ---------------------------------------------------------------------------
// ensureColumn adds a column to tableName if it does not already exist.
// NOTE: tableName and columnName are always hardcoded call-site constants —
// never derived from user input — so fmt.Sprintf is safe here.
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
if err != nil {
@ -646,7 +699,7 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n
}
// ---------------------------------------------------------------------------
// DB writer goroutines — both now respect config values and context shutdown
// DB writer goroutines
// ---------------------------------------------------------------------------
func startDBWriter(ctx context.Context, database *sql.DB) {
@ -716,7 +769,6 @@ func startDBWriter(ctx context.Context, database *sql.DB) {
for {
select {
case <-ctx.Done():
// Drain any remaining queued samples before exit
for {
select {
case s := <-sampleCh:
@ -738,7 +790,6 @@ func startDBWriter(ctx context.Context, database *sql.DB) {
}
func startAlarmWriter(ctx context.Context, database *sql.DB) {
// BUG FIX: was hardcoded 1000ms / 32 — now uses the same config values as startDBWriter
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
defer ticker.Stop()
@ -803,7 +854,6 @@ func startAlarmWriter(ctx context.Context, database *sql.DB) {
for {
select {
case <-ctx.Done():
// Drain remaining alarm events before exit
for {
select {
case a := <-alarmCh:
@ -1046,7 +1096,6 @@ func startPLCPoller(ctx context.Context) {
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
for {
// Check for shutdown before attempting a new connection
select {
case <-ctx.Done():
return
@ -1073,8 +1122,6 @@ func startPLCPoller(ctx context.Context) {
client := gos7.NewClient(handler)
log.Println("PLC connected successfully")
// BUG FIX: buf was allocated inside the inner loop, causing a heap
// allocation every poll cycle. Moved outside — reused each iteration.
buf := make([]byte, 8)
for {
@ -1277,13 +1324,6 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
// Trend / stability classification
// ---------------------------------------------------------------------------
// classifyDirection is a single generic direction classifier that replaces
// the two near-identical classifyForceDirection / classifyImbalanceDirection
// functions that existed previously.
//
// stableThreshold — abs(delta) below this value → "stable"
// posLabel — label when delta > threshold (e.g. "rising", "worsening")
// negLabel — label when delta < -threshold (e.g. "falling", "improving")
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
if oldCount < 3 || newCount < 3 {
return "insufficient_data"
@ -1538,14 +1578,11 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) {
})
}
// serveUI serves the pre-rendered UI page. PageData is derived solely from
// the immutable config, so we render the template once at startup and reuse.
func serveUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(cachedUI)
}
// initCachedUI renders the HTML template once at startup.
func initCachedUI() {
data := PageData{
Title: cfg.UI.Title,
@ -1563,6 +1600,15 @@ func initCachedUI() {
PollMs: cfg.PLC.PollMs,
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
DefaultTrendWindow: "15m",
ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true),
ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true),
ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true),
ShowOverview: boolValue(cfg.Modules.ShowOverview, true),
ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true),
ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true),
ShowGauges: boolValue(cfg.Modules.ShowGauges, true),
ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true),
}
var buf bytes.Buffer
@ -1600,7 +1646,7 @@ func main() {
defer db.Close()
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize) // BUG FIX: was hardcoded 512
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize)
initCachedUI()
@ -1611,7 +1657,6 @@ func main() {
cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)
// Graceful shutdown via SIGINT / SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@ -1622,8 +1667,13 @@ func main() {
go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
go func() { defer wg.Done(); startPLCPoller(ctx) }()
staticRoot, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("failed to mount embedded static files: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
mux.HandleFunc("/", serveUI)
mux.HandleFunc("/api/data", apiData)
mux.HandleFunc("/api/history", apiHistory)
@ -2025,10 +2075,12 @@ const uiHTML = `<!DOCTYPE html>
</div>
<div class="flex flex-col gap-4 xl:items-end">
{{if .ShowHeaderControls}}
<div class="flex flex-wrap gap-3 justify-end">
<button id="theme-toggle" class="control-btn" type="button">Light theme</button>
<button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
</div>
{{end}}
<div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
<div class="flex items-center gap-3">
@ -2043,6 +2095,7 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
{{if .ShowVerdict}}
<div id="verdict-card" class="verdict-card neutral mb-8">
<div>
<div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
@ -2050,7 +2103,9 @@ const uiHTML = `<!DOCTYPE html>
</div>
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
</div>
{{end}}
{{if .ShowSummaryBar}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
<div id="summary-force-card" class="summary-card neutral">
<div class="flex items-center gap-3">
@ -2085,7 +2140,9 @@ const uiHTML = `<!DOCTYPE html>
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
</div>
</div>
{{end}}
{{if .ShowOverview}}
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
<div>
@ -2122,7 +2179,9 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
{{end}}
{{if .ShowIntelligence}}
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
<div>
@ -2184,7 +2243,9 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
{{end}}
{{if .ShowAlarmTimeline}}
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
<div>
@ -2214,7 +2275,9 @@ const uiHTML = `<!DOCTYPE html>
</table>
</div>
</div>
{{end}}
{{if .ShowGauges}}
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-8 mb-8">
<div id="card-l" class="glass border border-white/10 rounded-3xl p-6 md:p-8 transition-all duration-300">
<div class="flex justify-between items-start mb-4 gap-6">
@ -2260,7 +2323,9 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
{{end}}
{{if .ShowTrendChart}}
<div class="chart-wrap">
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
@ -2289,6 +2354,7 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
{{end}}
</div>
<script>
@ -2304,6 +2370,15 @@ const uiHTML = `<!DOCTYPE html>
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
const STALE_MS = Math.max(POLL_MS * 4, 2500);
const SHOW_HEADER_CONTROLS = {{if .ShowHeaderControls}}true{{else}}false{{end}};
const SHOW_VERDICT = {{if .ShowVerdict}}true{{else}}false{{end}};
const SHOW_SUMMARY_BAR = {{if .ShowSummaryBar}}true{{else}}false{{end}};
const SHOW_OVERVIEW = {{if .ShowOverview}}true{{else}}false{{end}};
const SHOW_INTELLIGENCE = {{if .ShowIntelligence}}true{{else}}false{{end}};
const SHOW_ALARM_TIMELINE = {{if .ShowAlarmTimeline}}true{{else}}false{{end}};
const SHOW_GAUGES = {{if .ShowGauges}}true{{else}}false{{end}};
const SHOW_TREND_CHART = {{if .ShowTrendChart}}true{{else}}false{{end}};
const START_ANGLE = Math.PI * 0.75;
const END_ANGLE = Math.PI * 2.25;
@ -2332,6 +2407,16 @@ const uiHTML = `<!DOCTYPE html>
.replace(/'/g, '&#39;');
}
function setTextById(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function setTextBySelector(selector, text) {
const el = document.querySelector(selector);
if (el) el.textContent = text;
}
function colorMix(c1, c2, t) {
return {
r: Math.round(lerp(c1.r, c2.r, t)),
@ -2416,6 +2501,8 @@ const uiHTML = `<!DOCTYPE html>
}
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
if (!SHOW_GAUGES) return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
@ -2563,6 +2650,7 @@ const uiHTML = `<!DOCTYPE html>
function setConnectionIndicator(connected, stale) {
const dot = document.getElementById('dot');
const text = document.getElementById('status-text');
if (!dot || !text) return;
if (!connected) {
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
@ -2584,10 +2672,13 @@ const uiHTML = `<!DOCTYPE html>
}
function applyChannelState(side, percentValue) {
if (!SHOW_GAUGES) return;
const zone = getZone(percentValue);
const card = document.getElementById('card-' + side);
const led = document.getElementById('led-' + side);
const stateText = document.getElementById('state-' + side);
if (!card || !led || !stateText) return;
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
@ -2623,10 +2714,13 @@ const uiHTML = `<!DOCTYPE html>
}
function setSummaryCard(kind, zone, text, value) {
if (!SHOW_SUMMARY_BAR) return;
const card = document.getElementById('summary-' + kind + '-card');
const dot = document.getElementById('summary-' + kind + '-dot');
const status = document.getElementById('summary-' + kind + '-text');
const val = document.getElementById('summary-' + kind + '-value');
if (!card || !dot || !status || !val) return;
card.className = 'summary-card ' + zone;
dot.className = 'summary-dot ' + zone;
@ -2636,15 +2730,21 @@ const uiHTML = `<!DOCTYPE html>
}
function setVerdict(zone, statusText, reasonText) {
if (!SHOW_VERDICT) return;
const card = document.getElementById('verdict-card');
const status = document.getElementById('verdict-status');
const reason = document.getElementById('verdict-reason');
if (!card || !status || !reason) return;
card.className = 'verdict-card ' + zone;
status.textContent = statusText;
reason.textContent = reasonText;
}
function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) {
if (!SHOW_SUMMARY_BAR) return;
if (!connected) {
setSummaryCard('force', 'neutral', 'NO DATA', '--');
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
@ -2670,6 +2770,8 @@ const uiHTML = `<!DOCTYPE html>
}
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
if (!SHOW_VERDICT) return;
if (!connected) {
setVerdict('critical', 'OFFLINE', 'No PLC communication');
return;
@ -2712,6 +2814,7 @@ const uiHTML = `<!DOCTYPE html>
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) {
const banner = document.getElementById('alarm-banner');
const text = document.getElementById('alarm-text');
if (!banner || !text) return;
if (!connected) {
text.textContent = 'CRITICAL ALARM ACTIVE PLC OFFLINE';
@ -2752,6 +2855,7 @@ const uiHTML = `<!DOCTYPE html>
}
function redrawGauges() {
if (!SHOW_GAUGES) return;
if (!latestData) return;
const leftPercent = Number(latestData.sila_l) || 0;
@ -2805,9 +2909,13 @@ const uiHTML = `<!DOCTYPE html>
}
function setIntelCard(idPrefix, zone, valueText, subText) {
if (!SHOW_INTELLIGENCE) return;
const card = document.getElementById(idPrefix + '-card');
const value = document.getElementById(idPrefix + '-value');
const sub = document.getElementById(idPrefix + '-sub');
if (!card || !value || !sub) return;
card.className = 'intel-card ' + zone;
value.innerHTML = valueText;
sub.innerHTML = subText;
@ -2882,7 +2990,7 @@ const uiHTML = `<!DOCTYPE html>
}
function updateChartTheme() {
if (!lineChart) return;
if (!SHOW_TREND_CHART || !lineChart) return;
const light = isLightTheme();
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
@ -2928,19 +3036,23 @@ const uiHTML = `<!DOCTYPE html>
setConnectionIndicator(connected, stale);
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
if (SHOW_GAUGES) {
setTextBySelector('#digital-l .percent', leftPercent.toFixed(1));
setTextBySelector('#digital-l .kn', leftKN.toFixed(1) + ' ' + UNIT_FORCE);
setTextBySelector('#digital-r .percent', rightPercent.toFixed(1));
setTextBySelector('#digital-r .kn', rightKN.toFixed(1) + ' ' + UNIT_FORCE);
}
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
if (SHOW_OVERVIEW) {
setTextById('sum-percent', sumPercent.toFixed(1));
setTextById('sum-kn', sumKN.toFixed(1));
setTextById('imbalance-pct', imbalance.toFixed(1));
setTextById('bias-pct', bias.toFixed(1));
}
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1);
document.getElementById('bias-pct').textContent = bias.toFixed(1);
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
document.getElementById('dropped-samples').textContent = String(d.dropped_samples || 0);
document.getElementById('dropped-events').textContent = String(d.dropped_events || 0);
setTextById('last-update', formatLastUpdate(d.last_update));
setTextById('dropped-samples', String(d.dropped_samples || 0));
setTextById('dropped-events', String(d.dropped_events || 0));
applyChannelState('l', leftPercent);
applyChannelState('r', rightPercent);
@ -2958,6 +3070,7 @@ const uiHTML = `<!DOCTYPE html>
}
async function fetchHistory() {
if (!SHOW_TREND_CHART || !lineChart) return;
if (historyBusy) return;
historyBusy = true;
@ -2987,6 +3100,7 @@ const uiHTML = `<!DOCTYPE html>
}
async function fetchTrend() {
if (!SHOW_INTELLIGENCE) return;
if (trendBusy) return;
trendBusy = true;
@ -3057,6 +3171,7 @@ const uiHTML = `<!DOCTYPE html>
}
async function fetchAlarms() {
if (!SHOW_ALARM_TIMELINE) return;
if (alarmsBusy) return;
alarmsBusy = true;
@ -3066,6 +3181,7 @@ const uiHTML = `<!DOCTYPE html>
const d = await res.json();
const events = Array.isArray(d.events) ? d.events : [];
const body = document.getElementById('alarm-table-body');
if (!body) return;
if (events.length === 0) {
body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
@ -3110,12 +3226,14 @@ const uiHTML = `<!DOCTYPE html>
}
function useWindow(value) {
if (!SHOW_TREND_CHART) return;
currentWindow = value;
setActiveWindowButton(value);
fetchHistory();
}
function useTrendWindow(value) {
if (!SHOW_INTELLIGENCE) return;
currentTrendWindow = value;
setActiveTrendWindowButton(value);
fetchTrend();
@ -3127,8 +3245,12 @@ const uiHTML = `<!DOCTYPE html>
setActiveWindowButton(DEFAULT_WINDOW);
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('fullscreen-toggle').addEventListener('click', toggleFullscreen);
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));
@ -3138,97 +3260,110 @@ const uiHTML = `<!DOCTYPE html>
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
});
document.getElementById('apply-window').addEventListener('click', () => {
const val = document.getElementById('custom-window').value.trim();
if (!val) return;
currentWindow = val;
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
fetchHistory();
});
const applyWindowBtn = document.getElementById('apply-window');
if (applyWindowBtn) {
applyWindowBtn.addEventListener('click', () => {
const input = document.getElementById('custom-window');
const val = input ? input.value.trim() : '';
if (!val) return;
currentWindow = val;
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
fetchHistory();
});
}
document.getElementById('apply-trend-window').addEventListener('click', () => {
const val = document.getElementById('custom-trend-window').value.trim();
if (!val) return;
currentTrendWindow = val;
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
fetchTrend();
});
const applyTrendBtn = document.getElementById('apply-trend-window');
if (applyTrendBtn) {
applyTrendBtn.addEventListener('click', () => {
const input = document.getElementById('custom-trend-window');
const val = input ? input.value.trim() : '';
if (!val) return;
currentTrendWindow = val;
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
fetchTrend();
});
}
document.addEventListener('fullscreenchange', updateFullscreenButton);
updateFullscreenButton();
lineChart = new Chart(document.getElementById('lineChart'), {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Levi peak %',
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 0,
data: []
if (SHOW_TREND_CHART) {
const chartCanvas = document.getElementById('lineChart');
if (chartCanvas) {
lineChart = new Chart(chartCanvas, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Levi peak %',
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 0,
data: []
},
{
label: 'Desni peak %',
borderColor: '#c084fc',
backgroundColor: 'rgba(192,132,252,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 0,
data: []
},
{
label: 'Warning limit',
borderColor: 'rgba(245,158,11,0.95)',
borderWidth: 2,
pointRadius: 0,
borderDash: [8, 6],
tension: 0,
data: []
},
{
label: 'Critical limit',
borderColor: 'rgba(239,68,68,0.95)',
borderWidth: 2,
pointRadius: 0,
borderDash: [8, 6],
tension: 0,
data: []
}
]
},
{
label: 'Desni peak %',
borderColor: '#c084fc',
backgroundColor: 'rgba(192,132,252,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 0,
data: []
},
{
label: 'Warning limit',
borderColor: 'rgba(245,158,11,0.95)',
borderWidth: 2,
pointRadius: 0,
borderDash: [8, 6],
tension: 0,
data: []
},
{
label: 'Critical limit',
borderColor: 'rgba(239,68,68,0.95)',
borderWidth: 2,
pointRadius: 0,
borderDash: [8, 6],
tension: 0,
data: []
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: {
min: 0,
max: GAUGE_MAX_PERCENT,
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#a1a1aa', stepSize: 10 }
}
},
plugins: {
legend: { position: 'top', labels: { color: '#f4f4f5' } },
tooltip: {
backgroundColor: 'rgba(9,9,11,0.96)',
titleColor: '#f4f4f5',
bodyColor: '#f4f4f5'
}
}
}
]
},
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: {
min: 0,
max: GAUGE_MAX_PERCENT,
grid: { color: 'rgba(255,255,255,0.06)' },
ticks: { color: '#a1a1aa', stepSize: 10 }
}
},
plugins: {
legend: { position: 'top', labels: { color: '#f4f4f5' } },
tooltip: {
backgroundColor: 'rgba(9,9,11,0.96)',
titleColor: '#f4f4f5',
bodyColor: '#f4f4f5'
}
}
}
});
});
updateChartTheme();
updateChartTheme();
}
}
fetchLiveData();
fetchHistory();
@ -3236,9 +3371,18 @@ const uiHTML = `<!DOCTYPE html>
fetchAlarms();
setInterval(fetchLiveData, POLL_MS);
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
setInterval(fetchAlarms, 2500);
if (SHOW_TREND_CHART) {
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
}
if (SHOW_INTELLIGENCE) {
setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
}
if (SHOW_ALARM_TIMELINE) {
setInterval(fetchAlarms, 2500);
}
window.addEventListener('resize', redrawGauges);
};