From 81018b8abab1f5ff37432fef755ce427988132e0 Mon Sep 17 00:00:00 2001 From: Dejan R Date: Mon, 20 Apr 2026 18:05:11 +0200 Subject: [PATCH] removed index html from main --- main.go | 2003 ++++++++++--------------------------------------------- 1 file changed, 336 insertions(+), 1667 deletions(-) diff --git a/main.go b/main.go index a02907f..b91b0fe 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,11 @@ import ( "bytes" "context" "database/sql" - "embed" "encoding/json" "errors" "fmt" "html/template" - "io/fs" + "io" "log" "math" "net/http" @@ -31,10 +30,7 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed static -var staticFiles embed.FS - -const version = "0.9.3" +const version = "1.0.0" // --------------------------------------------------------------------------- // Config structs @@ -50,6 +46,7 @@ type Config struct { Modules ModulesConfig `yaml:"modules"` DB DBConfig `yaml:"db"` MQTT MQTTConfig `yaml:"mqtt"` + License LicenseConfig `yaml:"license"` } type ServerConfig struct { @@ -122,19 +119,17 @@ type DBConfig struct { CleanupIntervalHr int `yaml:"cleanup_interval_hours"` } -// MQTTConfig holds all MQTT broker and publishing settings. -// Requires restart when changed — not hot-reloadable. type MQTTConfig struct { Enabled bool `yaml:"enabled"` - Broker string `yaml:"broker"` // e.g. "tcp://192.168.1.10:1883" - ClientID string `yaml:"client_id"` // unique client identifier - Username string `yaml:"username"` // leave blank if no auth - Password string `yaml:"password"` // leave blank if no auth - TopicPrefix string `yaml:"topic_prefix"` // e.g. "plant1/press3" - QoS int `yaml:"qos"` // 0, 1, or 2 - Retain bool `yaml:"retain"` // retain auto-published state msgs - AutoPublish bool `yaml:"auto_publish"` // publish PLC state on timer - PublishIntervalMs int `yaml:"publish_interval_ms"` // how often to auto-publish + Broker string `yaml:"broker"` + ClientID string `yaml:"client_id"` + Username string `yaml:"username"` + Password string `yaml:"password"` + TopicPrefix string `yaml:"topic_prefix"` + QoS int `yaml:"qos"` + Retain bool `yaml:"retain"` + AutoPublish bool `yaml:"auto_publish"` + PublishIntervalMs int `yaml:"publish_interval_ms"` ConnectTimeoutSec int `yaml:"connect_timeout_sec"` ReconnectDelaySec int `yaml:"reconnect_delay_sec"` } @@ -217,6 +212,14 @@ func defaultConfig() Config { ConnectTimeoutSec: 10, ReconnectDelaySec: 5, }, + License: LicenseConfig{ + Enabled: true, + TrialDays: 7, + RequireAfterTrial: true, + DataDir: "license", + PublicKeyBase64: "", + ProductCode: "force_monitor", + }, } } @@ -226,10 +229,6 @@ func setIfZeroF(dst *float64, def float64) { } } -// setIfZeroI replaces *dst with def when *dst <= 0. -// Note: this means config values of 0 are treated as "not set". -// For PLC Rack (valid at 0), the default is also 0, so this is a safe no-op. -// For PLC Slot, a value of 0 would be overwritten with default 1. func setIfZeroI(dst *int, def int) { if *dst <= 0 { *dst = def @@ -256,7 +255,6 @@ func normalizeConfig(cfg *Config) { setIfEmpty(&cfg.PLC.IP, def.PLC.IP) setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum) - // Rack: default is 0 so setIfZeroI is a no-op; kept for symmetry. setIfZeroI(&cfg.PLC.Rack, def.PLC.Rack) setIfZeroI(&cfg.PLC.Slot, def.PLC.Slot) setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs) @@ -264,7 +262,6 @@ func normalizeConfig(cfg *Config) { setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec) setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec) - // Migrate legacy kN fields to percent fields if new fields are absent. if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 { cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn } @@ -320,7 +317,6 @@ func normalizeConfig(cfg *Config) { setIfZeroI(&cfg.DB.CheckpointPages, def.DB.CheckpointPages) setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr) - // MQTT normalization (only when enabled to avoid noisy defaults) if cfg.MQTT.Enabled { setIfEmpty(&cfg.MQTT.Broker, def.MQTT.Broker) setIfEmpty(&cfg.MQTT.ClientID, def.MQTT.ClientID) @@ -332,6 +328,13 @@ func normalizeConfig(cfg *Config) { setIfZeroI(&cfg.MQTT.ConnectTimeoutSec, def.MQTT.ConnectTimeoutSec) setIfZeroI(&cfg.MQTT.ReconnectDelaySec, def.MQTT.ReconnectDelaySec) } + + if !cfg.License.Enabled { + // keep defaults when disabled, but still normalize product code if provided + } + setIfZeroI(&cfg.License.TrialDays, def.License.TrialDays) + setIfEmpty(&cfg.License.DataDir, def.License.DataDir) + setIfEmpty(&cfg.License.ProductCode, def.License.ProductCode) } func loadConfigStrict(configPath string) (Config, error) { @@ -507,33 +510,20 @@ type AlarmResponse struct { Events []AlarmEventAPI `json:"events"` } -type PageData struct { - Title string - Subtitle string - LeftLabel string - RightLabel string - UnitForce string - UnitPct string - MaxTonnage float64 - WarningPercent float64 - CriticalPercent float64 - GaugeMaxPercent float64 - ImbalanceWarningPercent float64 - ImbalanceCriticalPercent float64 - PollMs int - DefaultWindow string - DefaultTrendWindow string +type PublicConfigResponse struct { + Version string `json:"version"` + UIRevision uint64 `json:"ui_revision"` + UI UIConfig `json:"ui"` + Thresholds ThresholdsConfig `json:"thresholds"` + Trend TrendConfig `json:"trend"` + Press PressConfig `json:"press"` + Modules ModulesConfig `json:"modules"` + LicenseHint LicenseHint `json:"license"` +} - ShowHeaderControls bool - ShowVerdict bool - ShowSummaryBar bool - ShowOverview bool - ShowIntelligence bool - ShowAlarmTimeline bool - ShowGauges bool - ShowGaugeDigital bool - ShowTrendChart bool - UIRevision uint64 +type LicenseHint struct { + Enabled bool `json:"enabled"` + TrialDays int `json:"trial_days"` } type NumericStats struct { @@ -569,7 +559,6 @@ type AlarmTracker struct { // MQTT types // --------------------------------------------------------------------------- -// MQTTReceivedMsg holds a single inbound MQTT message stored in history. type MQTTReceivedMsg struct { Topic string `json:"topic"` Payload string `json:"payload"` @@ -577,7 +566,6 @@ type MQTTReceivedMsg struct { Time string `json:"time"` } -// MQTTPublishRequest is the body for POST /api/mqtt/publish. type MQTTPublishRequest struct { Topic string `json:"topic"` Payload string `json:"payload"` @@ -585,13 +573,11 @@ type MQTTPublishRequest struct { Retain bool `json:"retain"` } -// MQTTSubscribeRequest is the body for POST /api/mqtt/subscribe. type MQTTSubscribeRequest struct { Topic string `json:"topic"` QoS int `json:"qos"` } -// MQTTStatusResponse is returned by GET /api/mqtt/status. type MQTTStatusResponse struct { Enabled bool `json:"enabled"` Connected bool `json:"connected"` @@ -602,8 +588,6 @@ type MQTTStatusResponse struct { LastError string `json:"last_error,omitempty"` } -// mqttManager wraps the paho MQTT client and tracks connection state, -// inbound message history, and active subscriptions. type mqttManager struct { mu sync.RWMutex client mqtt.Client @@ -614,7 +598,7 @@ type mqttManager struct { prefix string msgHistory []MQTTReceivedMsg msgMax int - subs map[string]byte // topic -> qos currently subscribed + subs map[string]byte } // --------------------------------------------------------------------------- @@ -622,22 +606,20 @@ type mqttManager struct { // --------------------------------------------------------------------------- var ( - cfg Config - cfgMu sync.RWMutex - state AppState - db *sql.DB - sampleCh chan Sample - alarmCh chan AlarmEvent + cfg Config + cfgMu sync.RWMutex + state AppState + db *sql.DB + sampleCh chan Sample + alarmCh chan AlarmEvent + alarmTracker AlarmTracker - uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) - cachedUI []byte uiRevision uint64 = 1 - // mqttMgr is nil when MQTT is disabled. - mqttMgr *mqttManager - - // mqttAlarmCh is non-nil when MQTT is enabled; used to avoid goroutine spam. + mqttMgr *mqttManager mqttAlarmCh chan AlarmEvent + + licenseMgr *LicenseManager ) // --------------------------------------------------------------------------- @@ -670,17 +652,14 @@ func newMQTTManager(mcfg MQTTConfig) *mqttManager { m.mu.Lock() m.connected = true m.lastErr = "" - // Snapshot current subs before releasing lock. - resubTopics := make(map[string]byte, len(m.subs)) - for t, q := range m.subs { - resubTopics[t] = q + resub := make(map[string]byte, len(m.subs)) + for k, v := range m.subs { + resub[k] = v } m.mu.Unlock() log.Printf("MQTT connected to %s", mcfg.Broker) - - // Re-subscribe on reconnect. - for topic, qos := range resubTopics { + for topic, qos := range resub { tok := c.Subscribe(topic, qos, m.messageHandler) if tok.Wait() && tok.Error() != nil { log.Printf("MQTT re-subscribe %s failed: %v", topic, tok.Error()) @@ -702,7 +681,6 @@ func newMQTTManager(mcfg MQTTConfig) *mqttManager { return m } -// connect dials the broker; paho handles all subsequent reconnections. func (m *mqttManager) connect() error { tok := m.client.Connect() if !tok.WaitTimeout(30 * time.Second) { @@ -717,14 +695,12 @@ func (m *mqttManager) connect() error { return nil } -// disconnect cleanly disconnects from the broker. func (m *mqttManager) disconnect() { if m.client != nil { m.client.Disconnect(500) } } -// messageHandler is the default callback for all subscriptions. func (m *mqttManager) messageHandler(_ mqtt.Client, msg mqtt.Message) { entry := MQTTReceivedMsg{ Topic: msg.Topic(), @@ -735,7 +711,6 @@ func (m *mqttManager) messageHandler(_ mqtt.Client, msg mqtt.Message) { m.mu.Lock() m.msgHistory = append(m.msgHistory, entry) if len(m.msgHistory) > m.msgMax { - // Trim oldest half to avoid O(n) shift on every overflow. half := m.msgMax / 2 copy(m.msgHistory, m.msgHistory[half:]) m.msgHistory = m.msgHistory[:m.msgMax-half] @@ -743,7 +718,6 @@ func (m *mqttManager) messageHandler(_ mqtt.Client, msg mqtt.Message) { m.mu.Unlock() } -// publish sends a message; returns an error if not connected or publish fails. func (m *mqttManager) publish(topic, payload string, qos byte, retain bool) error { if topic == "" { return fmt.Errorf("topic must not be empty") @@ -767,7 +741,6 @@ func (m *mqttManager) publish(topic, payload string, qos byte, retain bool) erro return nil } -// subscribe adds a topic subscription; resubscribed automatically on reconnect. func (m *mqttManager) subscribe(topic string, qos byte) error { if topic == "" { return fmt.Errorf("topic must not be empty") @@ -783,7 +756,6 @@ func (m *mqttManager) subscribe(topic string, qos byte) error { ok := m.connected m.mu.RUnlock() if !ok { - // Will be subscribed upon reconnect via OnConnectHandler. return nil } @@ -794,7 +766,6 @@ func (m *mqttManager) subscribe(topic string, qos byte) error { return nil } -// unsubscribe removes a topic subscription. func (m *mqttManager) unsubscribe(topic string) error { m.mu.Lock() delete(m.subs, topic) @@ -813,7 +784,6 @@ func (m *mqttManager) unsubscribe(topic string) error { return nil } -// status returns a snapshot of connection state for the API. func (m *mqttManager) status() MQTTStatusResponse { m.mu.RLock() defer m.mu.RUnlock() @@ -833,7 +803,6 @@ func (m *mqttManager) status() MQTTStatusResponse { } } -// getMessages returns up to limit of the most recently received messages. func (m *mqttManager) getMessages(limit int) []MQTTReceivedMsg { if limit <= 0 { limit = 50 @@ -857,14 +826,6 @@ func (m *mqttManager) getMessages(limit int) []MQTTReceivedMsg { return out } -// startMQTTPublisher periodically publishes the current PLC state to MQTT. -// Topics published: -// -// {prefix}/data – full JSON APIState (same as /api/data) -// {prefix}/force/left – left column force in % -// {prefix}/force/right – right column force in % -// {prefix}/force/sum_kn – total kN -// {prefix}/connected – PLC connection boolean func startMQTTPublisher(ctx context.Context) { if mqttMgr == nil { return @@ -908,16 +869,12 @@ func startMQTTPublisher(ctx context.Context) { } } -// mqttAlarmWorker drains the MQTT alarm channel so we don't spawn a goroutine per event. func mqttAlarmWorker() { for a := range mqttAlarmCh { mqttPublishAlarm(a) } } -// mqttPublishAlarm forwards a single alarm event to MQTT non-blocking. -// Called from mqttAlarmWorker; errors are silently discarded to avoid -// blocking the alarm pipeline. func mqttPublishAlarm(a AlarmEvent) { if mqttMgr == nil { return @@ -965,54 +922,6 @@ func getConfigSnapshot() Config { return cfg } -func buildCachedUI(config Config, revision uint64) ([]byte, error) { - data := PageData{ - Title: config.UI.Title, - Subtitle: config.UI.Subtitle, - LeftLabel: config.UI.LeftLabel, - RightLabel: config.UI.RightLabel, - UnitForce: config.UI.UnitForce, - UnitPct: config.UI.UnitPct, - MaxTonnage: config.Press.MaxTonnage, - WarningPercent: config.Thresholds.WarningPercent, - CriticalPercent: config.Thresholds.CriticalPercent, - GaugeMaxPercent: config.Thresholds.GaugeMaxPercent, - ImbalanceWarningPercent: config.Thresholds.ImbalanceWarningPercent, - ImbalanceCriticalPercent: config.Thresholds.ImbalanceCriticalPercent, - PollMs: config.PLC.PollMs, - DefaultWindow: fmt.Sprintf("%dm", config.Trend.Minutes), - DefaultTrendWindow: "15m", - - ShowHeaderControls: boolValue(config.Modules.ShowHeaderControls, true), - ShowVerdict: boolValue(config.Modules.ShowVerdict, true), - ShowSummaryBar: boolValue(config.Modules.ShowSummaryBar, true), - ShowOverview: boolValue(config.Modules.ShowOverview, true), - ShowIntelligence: boolValue(config.Modules.ShowIntelligence, true), - ShowAlarmTimeline: boolValue(config.Modules.ShowAlarmTimeline, true), - ShowGauges: boolValue(config.Modules.ShowGauges, true), - ShowGaugeDigital: boolValue(config.Modules.ShowGaugeDigital, false), - ShowTrendChart: boolValue(config.Modules.ShowTrendChart, true), - UIRevision: revision, - } - - var buf bytes.Buffer - if err := uiTemplate.Execute(&buf, data); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func initCachedUI() { - config := getConfigSnapshot() - payload, err := buildCachedUI(config, atomic.LoadUint64(&uiRevision)) - if err != nil { - log.Fatalf("failed to pre-render UI template: %v", err) - } - cfgMu.Lock() - cachedUI = payload - cfgMu.Unlock() -} - func hotReloadSectionsLocked(dst *Config, src Config) { dst.Thresholds = src.Thresholds dst.Trend = src.Trend @@ -1049,6 +958,9 @@ func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartS if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) { restartSections = append(restartSections, "mqtt") } + if !reflect.DeepEqual(oldCfg.License, newCfg.License) { + restartSections = append(restartSections, "license") + } return hotSections, restartSections } @@ -1066,21 +978,13 @@ func reloadConfigSafely(configPath string) { oldCfg := getConfigSnapshot() hotSections, restartSections := configSectionChanges(oldCfg, newCfg) - updatedCfg := oldCfg - hotReloadSectionsLocked(&updatedCfg, newCfg) - if len(hotSections) > 0 { - nextUIRevision := atomic.LoadUint64(&uiRevision) + 1 - payload, err := buildCachedUI(updatedCfg, nextUIRevision) - if err != nil { - log.Printf("config reload rejected: failed to rebuild UI: %v", err) - return - } cfgMu.Lock() - cfg = updatedCfg - cachedUI = payload - atomic.StoreUint64(&uiRevision, nextUIRevision) + updated := cfg + hotReloadSectionsLocked(&updated, newCfg) + cfg = updated cfgMu.Unlock() + atomic.AddUint64(&uiRevision, 1) } if len(hotSections) == 0 && len(restartSections) == 0 { @@ -1103,6 +1007,11 @@ func startConfigWatcher(ctx context.Context, configPath string) error { dir := filepath.Dir(configPath) target := filepath.Clean(configPath) + if !filepath.IsAbs(target) { + target = filepath.Join(dir, target) + } + target = filepath.Clean(target) + if err := watcher.Add(dir); err != nil { _ = watcher.Close() return err @@ -1142,7 +1051,11 @@ func startConfigWatcher(ctx context.Context, configPath string) error { if !ok { return } - if filepath.Clean(event.Name) != target { + name := event.Name + if !filepath.IsAbs(name) { + name = filepath.Join(dir, name) + } + if filepath.Clean(name) != target { continue } if event.Has(fsnotify.Chmod) { @@ -1219,7 +1132,6 @@ func enqueueAlarm(a AlarmEvent) { state.DroppedEvents++ state.Unlock() } - // Forward to MQTT non-blocking via single worker goroutine. if mqttAlarmCh != nil { select { case mqttAlarmCh <- a: @@ -1271,7 +1183,6 @@ func initDatabase(dbPath string, dbCfg DBConfig) (*sql.DB, error) { return nil, fmt.Errorf("open sqlite: %w", err) } - // FIX: allow parallel reads (writes still serialize safely via WAL). database.SetMaxOpenConns(4) database.SetMaxIdleConns(2) database.SetConnMaxLifetime(time.Hour) @@ -1326,13 +1237,11 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n return nil, fmt.Errorf("create schema: %w", err) } - // FIX: covering index for trend queries (sum_pct, imbalance_pct by time). if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_trend ON samples(ts_unix_ns, sum_pct, imbalance_pct);`); err != nil { _ = database.Close() return nil, fmt.Errorf("create trend index: %w", err) } - // Schema migrations: add missing columns to support older databases. migrations := []struct{ table, col, def string }{ {"samples", "ts_unix_ns", "INTEGER NOT NULL DEFAULT 0"}, {"samples", "imbalance_pct", "REAL NOT NULL DEFAULT 0"}, @@ -1346,13 +1255,11 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n } } - // Ensure the ts_unix_ns index exists (may have been missed pre-migration). if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns)`); err != nil { _ = database.Close() return nil, fmt.Errorf("create ts_unix_ns index: %w", err) } - // Backfill ts_unix_ns for rows that pre-date the column. for _, tbl := range []string{"samples", "alarm_events"} { q := fmt.Sprintf(`UPDATE %s SET ts_unix_ns = CAST(strftime('%%s', ts) AS INTEGER) * 1000000000 WHERE ts_unix_ns = 0 AND ts IS NOT NULL`, tbl) if _, err := database.Exec(q); err != nil { @@ -1360,7 +1267,6 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n } } - // FIX: update query planner stats so indexes are actually used. if _, err := database.Exec("ANALYZE"); err != nil { log.Printf("warning: sqlite analyze failed: %v", err) } @@ -1582,12 +1488,10 @@ func startDBCleanup(ctx context.Context, database *sql.DB, retentionDays, interv ticker := time.NewTicker(time.Duration(intervalHr) * time.Hour) defer ticker.Stop() - // Replace the cleanup function in startDBCleanup with this: cleanup := func() { cutoffNs := time.Now().AddDate(0, 0, -retentionDays).UTC().UnixNano() for _, tbl := range []string{"samples", "alarm_events"} { for { - // Select up to 5000 rowids to delete (SQLite supports LIMIT in SELECT) rows, err := database.QueryContext(ctx, fmt.Sprintf(`SELECT rowid FROM %s WHERE ts_unix_ns > 0 AND ts_unix_ns < ? LIMIT 5000`, tbl), cutoffNs, @@ -1606,16 +1510,9 @@ func startDBCleanup(ctx context.Context, database *sql.DB, retentionDays, interv rowids = append(rowids, rid) } rows.Close() - if err := rows.Err(); err != nil { - log.Printf("db cleanup %s rows error: %v", tbl, err) + if len(rowids) == 0 { break } - - if len(rowids) == 0 { - break // Nothing more to delete - } - - // Build DELETE IN (...) query placeholders := make([]string, len(rowids)) args := make([]any, len(rowids)) for i, rid := range rowids { @@ -1702,7 +1599,6 @@ func maybeLogZoneChange(source, prev, curr string, value float64) { return } - // FIX: 5-second cooldown per source to prevent oscillation spam. alarmTracker.Lock() if alarmTracker.LastChange == nil { alarmTracker.LastChange = make(map[string]time.Time) @@ -1760,7 +1656,6 @@ func maybeLogZoneChange(source, prev, curr string, value float64) { } func evaluateProcessAlarms(s Sample) { - // FIX: snapshot thresholds once to avoid repeated locking. config := getConfigSnapshot() warn := config.Thresholds.WarningPercent crit := config.Thresholds.CriticalPercent @@ -1772,13 +1667,17 @@ func evaluateProcessAlarms(s Sample) { imbZone := zoneFromValue(float64(s.ImbalancePercent), imbWarn, imbCrit) alarmTracker.Lock() - defer alarmTracker.Unlock() - maybeLogZoneChange("force_left", alarmTracker.LeftZone, leftZone, float64(s.SilaLPct)) - maybeLogZoneChange("force_right", alarmTracker.RightZone, rightZone, float64(s.SilaRPct)) - maybeLogZoneChange("imbalance", alarmTracker.ImbZone, imbZone, float64(s.ImbalancePercent)) + prevLeft := alarmTracker.LeftZone + prevRight := alarmTracker.RightZone + prevImb := alarmTracker.ImbZone alarmTracker.LeftZone = leftZone alarmTracker.RightZone = rightZone alarmTracker.ImbZone = imbZone + alarmTracker.Unlock() + + maybeLogZoneChange("force_left", prevLeft, leftZone, float64(s.SilaLPct)) + maybeLogZoneChange("force_right", prevRight, rightZone, float64(s.SilaRPct)) + maybeLogZoneChange("imbalance", prevImb, imbZone, float64(s.ImbalancePercent)) } func maybeLogPLCConnected() { @@ -1829,9 +1728,6 @@ func startPLCPoller(ctx context.Context) { bootCfg := getConfigSnapshot() pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second - - // FIX: cache frequently accessed config values to avoid lock contention. - maxTonnage := bootCfg.Press.MaxTonnage dbNum := bootCfg.PLC.DBNum for { @@ -1895,7 +1791,8 @@ func startPLCPoller(ctx context.Context) { silaL := helper.GetRealAt(buf, 0) silaR := helper.GetRealAt(buf, 4) - leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, maxTonnage) + cfgSnap := getConfigSnapshot() + leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfgSnap.Press.MaxTonnage) imbalance := float32(math.Abs(float64(silaL - silaR))) bias := silaL - silaR now := time.Now() @@ -1964,7 +1861,6 @@ func formatHistoryLabel(t time.Time, window time.Duration) string { return local.Format("15:04:05.000") } -// FIX: use ts_unix_ns directly instead of parsing RFC3339 strings. func queryHistory(ctx context.Context, window time.Duration) ([]HistoryPoint, error) { cutoffNs := time.Now().Add(-window).UTC().UnixNano() @@ -2001,12 +1897,10 @@ func queryHistory(ctx context.Context, window time.Duration) ([]HistoryPoint, er return downsamplePoints(points, maxPts), nil } -// FIX: remove unnecessary map allocation; indices are monotonic. func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint { if len(points) <= max || max < 3 { return points } - out := make([]HistoryPoint, max) step := float64(len(points)-1) / float64(max-1) for i := 0; i < max; i++ { @@ -2031,7 +1925,6 @@ func validField(field string) (string, error) { } } -// FIX: accept context so queries cancel when HTTP client disconnects. func queryNumericStats(ctx context.Context, field string, fromNs, toNs int64) (NumericStats, error) { safeField, err := validField(field) if err != nil { @@ -2106,10 +1999,9 @@ func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sa return "stable", "Process variation is low" } -// FIX: 1-second response cache so overlapping UI polls don't hammer the DB. var ( - trendCache atomic.Value // stores *trendCacheEntry - trendCacheTime int64 // atomic + trendCache atomic.Value + trendCacheTime int64 ) type trendCacheEntry struct { @@ -2117,7 +2009,6 @@ type trendCacheEntry struct { Resp TrendResponse } -// FIX: accept context and use cache. func buildTrendResponse(ctx context.Context, window time.Duration, label string) (TrendResponse, error) { now := time.Now().UnixMilli() if cached, ok := trendCache.Load().(*trendCacheEntry); ok { @@ -2208,7 +2099,6 @@ func buildTrendResponse(ctx context.Context, window time.Duration, label string) return resp, nil } -// FIX: use ts_unix_ns directly and accept context. func queryAlarmEvents(ctx context.Context, limit int) ([]AlarmEventAPI, error) { if limit <= 0 { limit = 20 @@ -2248,7 +2138,6 @@ func queryAlarmEvents(ctx context.Context, limit int) ([]AlarmEventAPI, error) { // HTTP helpers // --------------------------------------------------------------------------- -// writeJSON writes v as JSON with correct headers and CORS. func writeJSON(w http.ResponseWriter, status int, v any) { h := w.Header() h.Set("Content-Type", "application/json") @@ -2258,8 +2147,6 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } -// allowMethod checks r.Method and handles CORS preflight. -// Returns false if the caller should stop processing. func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool { if r.Method == http.MethodOptions { h := w.Header() @@ -2276,6 +2163,22 @@ func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool { return true } +func requireActiveLicense(w http.ResponseWriter, r *http.Request) bool { + if licenseMgr == nil { + return true + } + status := licenseMgr.Status() + if !status.Locked { + _ = licenseMgr.Touch() + return true + } + writeJSON(w, http.StatusForbidden, map[string]any{ + "error": "license required", + "license": status, + }) + return false +} + // --------------------------------------------------------------------------- // HTTP handlers — core // --------------------------------------------------------------------------- @@ -2284,6 +2187,9 @@ func apiData(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return } + if !requireActiveLicense(w, r) { + return + } writeJSON(w, http.StatusOK, snapshotState()) } @@ -2294,10 +2200,34 @@ func apiUIRevision(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]uint64{"revision": atomic.LoadUint64(&uiRevision)}) } +func apiPublicConfig(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodGet) { + return + } + c := getConfigSnapshot() + resp := PublicConfigResponse{ + Version: version, + UIRevision: atomic.LoadUint64(&uiRevision), + UI: c.UI, + Thresholds: c.Thresholds, + Trend: c.Trend, + Press: c.Press, + Modules: c.Modules, + LicenseHint: LicenseHint{ + Enabled: c.License.Enabled, + TrialDays: c.License.TrialDays, + }, + } + writeJSON(w, http.StatusOK, resp) +} + func apiHistory(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return } + if !requireActiveLicense(w, r) { + return + } window, label, err := parseWindow(r.URL.Query().Get("window")) if err != nil { http.Error(w, `{"error":"invalid window"}`, http.StatusBadRequest) @@ -2316,6 +2246,9 @@ func apiTrend(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return } + if !requireActiveLicense(w, r) { + return + } window, label, err := parseWindow(r.URL.Query().Get("window")) if err != nil { http.Error(w, `{"error":"invalid trend window"}`, http.StatusBadRequest) @@ -2334,6 +2267,9 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return } + if !requireActiveLicense(w, r) { + return + } limit := 20 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 { @@ -2350,21 +2286,68 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) { } func serveUI(w http.ResponseWriter, r *http.Request) { - cfgMu.RLock() - payload := cachedUI - cfgMu.RUnlock() + if r.URL.Path == "/" { + // Parse and execute template with config data + tmpl, err := template.ParseFiles(filepath.Join("static", "index.html")) + if err != nil { + log.Printf("template parse error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, max-age=0") - _, _ = w.Write(payload) + c := getConfigSnapshot() + data := struct { + Title, Subtitle, LeftLabel, RightLabel, UnitForce, UnitPct string + WarningPercent, CriticalPercent, GaugeMaxPercent float64 + ImbalanceWarningPercent, ImbalanceCriticalPercent float64 + MaxTonnage float64 + PollMs int + DefaultWindow, DefaultTrendWindow string + UIRevision uint64 + ShowHeaderControls, ShowVerdict, ShowSummaryBar bool + ShowOverview, ShowIntelligence, ShowAlarmTimeline bool + ShowGauges, ShowGaugeDigital, ShowTrendChart bool + }{ + Title: c.UI.Title, + Subtitle: c.UI.Subtitle, + LeftLabel: c.UI.LeftLabel, + RightLabel: c.UI.RightLabel, + UnitForce: c.UI.UnitForce, + UnitPct: c.UI.UnitPct, + WarningPercent: c.Thresholds.WarningPercent, + CriticalPercent: c.Thresholds.CriticalPercent, + GaugeMaxPercent: c.Thresholds.GaugeMaxPercent, + ImbalanceWarningPercent: c.Thresholds.ImbalanceWarningPercent, + ImbalanceCriticalPercent: c.Thresholds.ImbalanceCriticalPercent, + MaxTonnage: c.Press.MaxTonnage, + PollMs: c.PLC.PollMs, + DefaultWindow: fmt.Sprintf("%dm", c.Trend.Minutes), + DefaultTrendWindow: fmt.Sprintf("%dm", c.Trend.Minutes), + UIRevision: atomic.LoadUint64(&uiRevision), + ShowHeaderControls: boolValue(c.Modules.ShowHeaderControls, true), + ShowVerdict: boolValue(c.Modules.ShowVerdict, true), + ShowSummaryBar: boolValue(c.Modules.ShowSummaryBar, true), + ShowOverview: boolValue(c.Modules.ShowOverview, true), + ShowIntelligence: boolValue(c.Modules.ShowIntelligence, true), + ShowAlarmTimeline: boolValue(c.Modules.ShowAlarmTimeline, true), + ShowGauges: boolValue(c.Modules.ShowGauges, true), + ShowGaugeDigital: boolValue(c.Modules.ShowGaugeDigital, false), + ShowTrendChart: boolValue(c.Modules.ShowTrendChart, true), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("template execute error: %v", err) + } + return + } + http.NotFound(w, r) } // --------------------------------------------------------------------------- // HTTP handlers — MQTT REST API // --------------------------------------------------------------------------- -// GET /api/mqtt/status -// Returns MQTT connection state, broker info, and active subscriptions. func apiMQTTStatus(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return @@ -2381,13 +2364,13 @@ func apiMQTTStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, mqttMgr.status()) } -// POST /api/mqtt/publish -// Body: {"topic":"plant/press/cmd","payload":"reset","qos":1,"retain":false} -// Publishes an arbitrary message to the MQTT broker. func apiMQTTPublish(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodPost) { return } + if !requireActiveLicense(w, r) { + return + } if mqttMgr == nil { http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable) return @@ -2414,12 +2397,13 @@ func apiMQTTPublish(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "published", "topic": req.Topic}) } -// GET /api/mqtt/messages?limit=50 -// Returns the most recently received MQTT messages (across all subscribed topics). func apiMQTTMessages(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodGet) { return } + if !requireActiveLicense(w, r) { + return + } if mqttMgr == nil { writeJSON(w, http.StatusOK, map[string]any{"messages": []MQTTReceivedMsg{}, "enabled": false}) return @@ -2434,14 +2418,13 @@ func apiMQTTMessages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"messages": msgs, "count": len(msgs)}) } -// POST /api/mqtt/subscribe -// Body: {"topic":"plant/press/#","qos":1} -// Subscribes to a topic filter. Messages are stored in the ring buffer -// and accessible via GET /api/mqtt/messages. func apiMQTTSubscribe(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodPost) { return } + if !requireActiveLicense(w, r) { + return + } if mqttMgr == nil { http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable) return @@ -2468,13 +2451,13 @@ func apiMQTTSubscribe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "subscribed", "topic": req.Topic}) } -// DELETE /api/mqtt/subscribe -// Body: {"topic":"plant/press/#"} -// Unsubscribes from a topic filter. func apiMQTTUnsubscribe(w http.ResponseWriter, r *http.Request) { if !allowMethod(w, r, http.MethodDelete) { return } + if !requireActiveLicense(w, r) { + return + } if mqttMgr == nil { http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable) return @@ -2497,6 +2480,116 @@ func apiMQTTUnsubscribe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "unsubscribed", "topic": req.Topic}) } +// --------------------------------------------------------------------------- +// HTTP handlers — License API +// --------------------------------------------------------------------------- + +type activateRequestBody struct { + LicenseText string `json:"license_text"` +} + +func apiLicenseStatus(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodGet) { + return + } + if licenseMgr == nil { + writeJSON(w, http.StatusOK, LicenseStatus{ + Enabled: false, + Mode: "disabled", + Message: "licensing disabled", + }) + return + } + writeJSON(w, http.StatusOK, licenseMgr.Status()) +} + +func apiLicenseRequest(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodGet) { + return + } + if licenseMgr == nil { + writeJSON(w, http.StatusOK, map[string]any{"enabled": false}) + return + } + req := licenseMgr.BuildActivationRequest() + writeJSON(w, http.StatusOK, req) +} + +func apiLicenseActivate(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodPost) { + return + } + if licenseMgr == nil { + http.Error(w, `{"error":"licensing disabled"}`, http.StatusServiceUnavailable) + return + } + + raw, err := ioReadAllLimit(r.Body, 1<<20) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + payload := strings.TrimSpace(string(raw)) + if payload == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request body is empty"}) + return + } + + licenseText := payload + var wrapped activateRequestBody + if err := json.Unmarshal(raw, &wrapped); err == nil && strings.TrimSpace(wrapped.LicenseText) != "" { + licenseText = wrapped.LicenseText + } + + if err := licenseMgr.ActivateFromText(licenseText); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": err.Error(), + "license": licenseMgr.Status(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "activated", + "license": licenseMgr.Status(), + }) +} + +// --------------------------------------------------------------------------- +// Misc helpers +// --------------------------------------------------------------------------- + +func ioReadAllLimit(r io.Reader, max int64) ([]byte, error) { + lr := &io.LimitedReader{R: r, N: max + 1} + data, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("payload too large") + } + return data, nil +} + +func startLicenseHeartbeat(ctx context.Context) { + if licenseMgr == nil { + return + } + _ = licenseMgr.Touch() + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := licenseMgr.Touch(); err != nil { + log.Printf("license heartbeat failed: %v", err) + } + } + } +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -2521,32 +2614,40 @@ func main() { dbPath = filepath.Join(wd, dbPath) } - // Pass DB config explicitly so initDatabase doesn't touch the global. db, err = initDatabase(dbPath, cfg.DB) if err != nil { log.Fatalf("failed to init database: %v", err) } defer db.Close() + licenseDataDir := cfg.License.DataDir + if !filepath.IsAbs(licenseDataDir) { + licenseDataDir = filepath.Join(wd, licenseDataDir) + } + licenseMgr, err = NewLicenseManager(cfg.License, licenseDataDir) + if err != nil { + log.Fatalf("failed to initialize license manager: %v", err) + } + sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize) - initCachedUI() - log.Printf("S7-1200 Force Monitor v%s", version) log.Printf("Config: %s", configPath) log.Printf("DB: %s", dbPath) log.Printf("PLC: ip=%s db=%d rack=%d slot=%d poll=%dms", 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) + if licenseMgr != nil { + ls := licenseMgr.Status() + log.Printf("License: mode=%s locked=%v fingerprint=%s", ls.Mode, ls.Locked, ls.FingerprintShort) + } - // Initialise MQTT if enabled. if cfg.MQTT.Enabled { mqttMgr = newMQTTManager(cfg.MQTT) mqttAlarmCh = make(chan AlarmEvent, 256) go mqttAlarmWorker() if err := mqttMgr.connect(); err != nil { - // Non-fatal: paho will reconnect automatically. log.Printf("MQTT initial connect failed (will retry): %v", err) } else { log.Printf("MQTT: connected to %s (prefix=%s)", cfg.MQTT.Broker, cfg.MQTT.TopicPrefix) @@ -2564,7 +2665,6 @@ func main() { log.Printf("Config watcher enabled for %s", configPath) } - // Snapshot DB config values once (DB is not hot-reloadable). dbCfg := cfg.DB var wg sync.WaitGroup @@ -2579,35 +2679,33 @@ func main() { go func() { defer wg.Done(); startMQTTPublisher(ctx) }() } - staticRoot, err := fs.Sub(staticFiles, "static") - if err != nil { - log.Fatalf("failed to mount embedded static files: %v", err) - } + wg.Add(1) + go func() { defer wg.Done(); startLicenseHeartbeat(ctx) }() mux := http.NewServeMux() - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot)))) + + staticDir := http.Dir("static") + fileServer := http.FileServer(staticDir) + mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) mux.HandleFunc("/", serveUI) - // Core data API mux.HandleFunc("/api/data", apiData) mux.HandleFunc("/api/ui-revision", apiUIRevision) + mux.HandleFunc("/api/config/public", apiPublicConfig) mux.HandleFunc("/api/history", apiHistory) mux.HandleFunc("/api/trend", apiTrend) mux.HandleFunc("/api/alarms", apiAlarms) - // MQTT REST API - // - // GET /api/mqtt/status → connection status, broker, subscriptions - // POST /api/mqtt/publish → publish message to any topic - // GET /api/mqtt/messages[?limit=N] → last N received messages - // POST /api/mqtt/subscribe → subscribe to topic filter - // DELETE /api/mqtt/subscribe → unsubscribe from topic filter mux.HandleFunc("/api/mqtt/status", apiMQTTStatus) mux.HandleFunc("/api/mqtt/publish", apiMQTTPublish) mux.HandleFunc("/api/mqtt/messages", apiMQTTMessages) mux.HandleFunc("/api/mqtt/subscribe", apiMQTTSubscribe) mux.HandleFunc("/api/mqtt/unsubscribe", apiMQTTUnsubscribe) + mux.HandleFunc("/api/license/status", apiLicenseStatus) + mux.HandleFunc("/api/license/request", apiLicenseRequest) + mux.HandleFunc("/api/license/activate", apiLicenseActivate) + srv := &http.Server{ Addr: cfg.Server.ListenAddr, Handler: mux, @@ -2617,7 +2715,7 @@ func main() { } log.Printf("Listening on http://localhost%s", cfg.Server.ListenAddr) - log.Printf("MQTT API: GET /api/mqtt/status | POST /api/mqtt/publish | GET /api/mqtt/messages") + log.Printf("License API: GET /api/license/status | GET /api/license/request | POST /api/license/activate") go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -2642,1432 +2740,3 @@ func main() { wg.Wait() log.Println("Shutdown complete") } - -const uiHTML = ` - - - - - {{.Title}} - - - - - -
- - -
-
-

{{.Title}}

-

{{.Subtitle}}

-

MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}

-
- -
- {{if .ShowHeaderControls}} -
- - -
- {{end}} - -
-
-
- Disconnected -
- -
Last update: --:--:--.---
- -
Dropped S: 0 | E: 0
-
-
-
- -
- {{if .ShowVerdict}} -
-
-
Machine verdict
-
NO DATA
-
-
Waiting for PLC data
-
- {{end}} - - {{if .ShowSummaryBar}} -
-
-
-
-
-
FORCE
-
NO DATA
-
-
-
--
-
- -
-
-
-
-
IMBALANCE
-
NO DATA
-
-
-
--
-
- -
-
-
-
-
PLC
-
OFFLINE
-
-
-
Disconnected
-
-
- {{end}} - - {{if .ShowOverview}} -
-
-
-
TOTAL PEAK FORCE
-
-
0.0
-
{{.UnitForce}}
-
-
- -
-
-
TOTAL %
-
0.0 {{.UnitPct}}
-
-
-
IMBALANCE
-
0.0 {{.UnitPct}}
-
abs(L - R)
-
-
-
BIAS
-
0.0 {{.UnitPct}}
-
L - R
-
-
-
LIMITS
-
Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}
-
Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}
-
-
-
-
- {{end}} - - {{if .ShowIntelligence}} -
-
-
-

Drift / Deterioration Intelligence

-
Averages, drift direction, imbalance deterioration and process stability
-
-
- - - - -
- - -
-
-
-
-
-
AVG PEAK 5 MIN
-
--
-
No data
-
-
-
AVG PEAK 1 HOUR
-
--
-
No data
-
-
-
FORCE TREND
-
--
-
No data
-
-
-
IMBALANCE TREND
-
--
-
No data
-
-
-
PROCESS STABILITY
-
--
-
No data
-
-
-
- {{end}} - - {{if .ShowAlarmTimeline}} -
-
-
-

Event / Alarm Timeline

-
Recent transitions show exactly when the process began drifting, overloading, losing balance, or losing PLC communication
-
-
Newest events first • clear events included
-
-
- - - - - - - - - - - - - - -
TimeSeveritySourceEventValueLimit
No events yet
-
-
- {{end}} - - {{if .ShowGauges}} -
-
- {{if .ShowGaugeDigital}} -
-
-
-
-

{{.LeftLabel}}

-
NORMAL
-
-
-
-
0.0
-
{{.UnitPct}}
-
0.0 {{.UnitForce}}
-
-
- {{else}} -
-
-
-

{{.LeftLabel}}

-
NORMAL
-
-
- {{end}} -
- -
-
- -
- {{if .ShowGaugeDigital}} -
-
-
-
-

{{.RightLabel}}

-
NORMAL
-
-
-
-
0.0
-
{{.UnitPct}}
-
0.0 {{.UnitForce}}
-
-
- {{else}} -
-
-
-

{{.RightLabel}}

-
NORMAL
-
-
- {{end}} -
- -
-
-
- {{end}} - - {{if .ShowTrendChart}} -
-
-
-
-

Peak Trend

-
Piezo peak/stroke history from SQLite with visible warning and critical limits
-
-
- - - - - - - -
- - -
-
-
-
- -
-
-
- {{end}} -
-
- - - -`