diff --git a/main.go b/main.go index 7a8c0ec..4e4c01b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "encoding/json" "errors" "fmt" @@ -9,17 +10,17 @@ import ( "net/http" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "time" + _ "github.com/mattn/go-sqlite3" "github.com/robinson/gos7" "gopkg.in/yaml.v3" ) -// ============================================= -// Configuration -// ============================================= type Config struct { Server ServerConfig `yaml:"server"` PLC PLCConfig `yaml:"plc"` @@ -27,6 +28,7 @@ type Config struct { Trend TrendConfig `yaml:"trend"` Press PressConfig `yaml:"press"` UI UIConfig `yaml:"ui"` + DB DBConfig `yaml:"db"` } type ServerConfig struct { @@ -49,7 +51,6 @@ type ThresholdsConfig struct { CriticalPercent float64 `yaml:"critical_percent"` GaugeMaxPercent float64 `yaml:"gauge_max_percent"` - // legacy compatibility LegacyWarningKn float64 `yaml:"warning_kn,omitempty"` LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"` LegacyMaxKn float64 `yaml:"max_kn,omitempty"` @@ -60,9 +61,7 @@ type TrendConfig struct { } type PressConfig struct { - MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"` - - // legacy compatibility + MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"` LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"` } @@ -75,11 +74,21 @@ type UIConfig struct { UnitPct string `yaml:"unit_percent"` } +type DBConfig struct { + Path string `yaml:"path"` + BusyTimeoutMs int `yaml:"busy_timeout_ms"` + BatchSize int `yaml:"batch_size"` + FlushIntervalMs int `yaml:"flush_interval_ms"` + RetentionDays int `yaml:"retention_days"` + MaxChartPoints int `yaml:"max_chart_points"` + WriterQueueSize int `yaml:"writer_queue_size"` + CheckpointPages int `yaml:"checkpoint_pages"` + CleanupIntervalHr int `yaml:"cleanup_interval_hours"` +} + func defaultConfig() Config { return Config{ - Server: ServerConfig{ - ListenAddr: ":8080", - }, + Server: ServerConfig{ListenAddr: ":8080"}, PLC: PLCConfig{ IP: "192.168.0.1", DBNum: 1001, @@ -91,16 +100,12 @@ func defaultConfig() Config { ReconnectDelaySec: 5, }, Thresholds: ThresholdsConfig{ - WarningPercent: 80.0, - CriticalPercent: 95.0, - GaugeMaxPercent: 130.0, - }, - Trend: TrendConfig{ - Minutes: 5, - }, - Press: PressConfig{ - MAX_TONNAGE: 64.0, + WarningPercent: 80, + CriticalPercent: 95, + GaugeMaxPercent: 130, }, + Trend: TrendConfig{Minutes: 5}, + Press: PressConfig{MAX_TONNAGE: 64}, UI: UIConfig{ Title: "Force Monitor", Subtitle: "Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE", @@ -109,6 +114,17 @@ func defaultConfig() Config { UnitForce: "kN", UnitPct: "%", }, + DB: DBConfig{ + Path: "force_monitor.db", + BusyTimeoutMs: 5000, + BatchSize: 32, + FlushIntervalMs: 1000, + RetentionDays: 30, + MaxChartPoints: 2000, + WriterQueueSize: 4096, + CheckpointPages: 1000, + CleanupIntervalHr: 6, + }, } } @@ -138,7 +154,6 @@ func normalizeConfig(cfg *Config) { cfg.PLC.ReconnectDelaySec = def.PLC.ReconnectDelaySec } - // backward compatibility with old names if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 { cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn } @@ -148,7 +163,6 @@ func normalizeConfig(cfg *Config) { if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 { cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn } - if cfg.Thresholds.WarningPercent <= 0 { cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent } @@ -188,6 +202,34 @@ func normalizeConfig(cfg *Config) { if strings.TrimSpace(cfg.UI.UnitPct) == "" { cfg.UI.UnitPct = def.UI.UnitPct } + + if strings.TrimSpace(cfg.DB.Path) == "" { + cfg.DB.Path = def.DB.Path + } + if cfg.DB.BusyTimeoutMs <= 0 { + cfg.DB.BusyTimeoutMs = def.DB.BusyTimeoutMs + } + if cfg.DB.BatchSize <= 0 { + cfg.DB.BatchSize = def.DB.BatchSize + } + if cfg.DB.FlushIntervalMs <= 0 { + cfg.DB.FlushIntervalMs = def.DB.FlushIntervalMs + } + if cfg.DB.RetentionDays <= 0 { + cfg.DB.RetentionDays = def.DB.RetentionDays + } + if cfg.DB.MaxChartPoints <= 0 { + cfg.DB.MaxChartPoints = def.DB.MaxChartPoints + } + if cfg.DB.WriterQueueSize <= 0 { + cfg.DB.WriterQueueSize = def.DB.WriterQueueSize + } + if cfg.DB.CheckpointPages <= 0 { + cfg.DB.CheckpointPages = def.DB.CheckpointPages + } + if cfg.DB.CleanupIntervalHr <= 0 { + cfg.DB.CleanupIntervalHr = def.DB.CleanupIntervalHr + } } func loadOrCreateConfig(configPath string) (Config, error) { @@ -199,11 +241,9 @@ func loadOrCreateConfig(configPath string) (Config, error) { if marshalErr != nil { return cfg, fmt.Errorf("failed to marshal default config: %w", marshalErr) } - if writeErr := os.WriteFile(configPath, data, 0644); writeErr != nil { return cfg, fmt.Errorf("failed to create config file: %w", writeErr) } - log.Printf("config file not found, created default config: %s", configPath) return cfg, nil } @@ -215,7 +255,6 @@ func loadOrCreateConfig(configPath string) (Config, error) { if err != nil { return cfg, fmt.Errorf("failed to read config file: %w", err) } - if err := yaml.Unmarshal(data, &cfg); err != nil { return cfg, fmt.Errorf("failed to parse config file: %w", err) } @@ -224,87 +263,96 @@ func loadOrCreateConfig(configPath string) (Config, error) { return cfg, nil } -func getMaxHistoryPoints(cfg Config) int { - points := (cfg.Trend.Minutes*60*1000/cfg.PLC.PollMs + 2) - if points < 10 { - return 10 - } - return points -} - -// ============================================= -// Data Structures -// ============================================= type Measurement struct { Time string `json:"time"` - SilaL float32 `json:"sila_l"` // % - SilaR float32 `json:"sila_r"` // % + SilaL float32 `json:"sila_l"` + SilaR float32 `json:"sila_r"` +} + +type Sample struct { + TS time.Time + SilaLPct float32 + SilaRPct float32 + SilaLKN float32 + SilaRKN float32 + SumPercent float32 + SumKN float32 } type AppState struct { - sync.Mutex - Connected bool - SilaL float32 // % - SilaR float32 // % - - SilaLkN float32 - SilaRkN float32 - SumPercent float32 - SumkN float32 - - History []Measurement - LastUpdate time.Time + sync.RWMutex + Connected bool + SilaL float32 + SilaR float32 + SilaLkN float32 + SilaRkN float32 + SumPercent float32 + SumkN float32 + LastUpdate time.Time + DroppedSamples uint64 } type APIState struct { - Connected bool `json:"connected"` - SilaL float32 `json:"sila_l"` - SilaR float32 `json:"sila_r"` - SilaLkN float32 `json:"sila_l_kn"` - SilaRkN float32 `json:"sila_r_kn"` - SumPercent float32 `json:"sum_percent"` - SumkN float32 `json:"sum_kn"` - History []Measurement `json:"history"` - LastUpdate string `json:"last_update"` + Connected bool `json:"connected"` + SilaL float32 `json:"sila_l"` + SilaR float32 `json:"sila_r"` + SilaLkN float32 `json:"sila_l_kn"` + SilaRkN float32 `json:"sila_r_kn"` + SumPercent float32 `json:"sum_percent"` + SumkN float32 `json:"sum_kn"` + LastUpdate string `json:"last_update"` + DroppedSamples uint64 `json:"dropped_samples"` +} + +type HistoryPoint struct { + Time string `json:"time"` + SilaL float32 `json:"sila_l"` + SilaR float32 `json:"sila_r"` +} + +type HistoryResponse struct { + Window string `json:"window"` + Points []HistoryPoint `json:"points"` } type PageData struct { - Title string - Subtitle string - LeftLabel string - RightLabel string - UnitForce string - UnitPct string - MaxTonnage float64 - WarningPercent float64 - CriticalPercent float64 - GaugeMaxPercent float64 - TrendMinutes int - MaxHistoryPoints int - PollMs int + Title string + Subtitle string + LeftLabel string + RightLabel string + UnitForce string + UnitPct string + MaxTonnage float64 + WarningPercent float64 + CriticalPercent float64 + GaugeMaxPercent float64 + PollMs int + DefaultWindow string } var ( - state AppState - cfg Config - uiTmpl = template.Must(template.New("ui").Parse(uiHTML)) + cfg Config + state AppState + db *sql.DB + sampleCh chan Sample + uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) ) -// ============================================= -// Helpers -// ============================================= -func setDisconnected() { - state.Lock() - state.Connected = false - state.Unlock() +func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) { + lp := float64(leftPercent) + rp := float64(rightPercent) + + sumPct := (lp + rp) / 2.0 + left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) + right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) + total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE + + return float32(left), float32(right), float32(sumPct), float32(total) } func snapshotState() APIState { - state.Lock() - defer state.Unlock() - - historyCopy := make([]Measurement, len(state.History)) - copy(historyCopy, state.History) + state.RLock() + defer state.RUnlock() lastUpdate := "" if !state.LastUpdate.IsZero() { @@ -312,40 +360,174 @@ func snapshotState() APIState { } return APIState{ - Connected: state.Connected, - SilaL: state.SilaL, - SilaR: state.SilaR, - SilaLkN: state.SilaLkN, - SilaRkN: state.SilaRkN, - SumPercent: state.SumPercent, - SumkN: state.SumkN, - History: historyCopy, - LastUpdate: lastUpdate, + Connected: state.Connected, + SilaL: state.SilaL, + SilaR: state.SilaR, + SilaLkN: state.SilaLkN, + SilaRkN: state.SilaRkN, + SumPercent: state.SumPercent, + SumkN: state.SumkN, + LastUpdate: lastUpdate, + DroppedSamples: state.DroppedSamples, } } -func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) { - lp := float64(leftPercent) - rp := float64(rightPercent) - - sumPct := (lp + rp) / 2.0 - - // each column contributes half of total nominal press force - left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) - right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) - - // total force by rule - total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE - - return float32(left), float32(right), float32(sumPct), float32(total) +func setDisconnected() { + state.Lock() + state.Connected = false + state.Unlock() } -// ============================================= -// PLC Poller -// ============================================= -func startPLCPoller() { - maxHistoryPoints := getMaxHistoryPoints(cfg) +func enqueueSample(s Sample) { + select { + case sampleCh <- s: + default: + state.Lock() + state.DroppedSamples++ + state.Unlock() + } +} +func initDatabase(dbPath string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs) + database, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + database.SetMaxOpenConns(1) + database.SetMaxIdleConns(1) + database.SetConnMaxLifetime(0) + + pragmas := []string{ + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=NORMAL;", + fmt.Sprintf("PRAGMA wal_autocheckpoint=%d;", cfg.DB.CheckpointPages), + fmt.Sprintf("PRAGMA busy_timeout=%d;", cfg.DB.BusyTimeoutMs), + "PRAGMA temp_store=MEMORY;", + } + + for _, q := range pragmas { + if _, err := database.Exec(q); err != nil { + _ = database.Close() + return nil, fmt.Errorf("sqlite pragma failed (%s): %w", q, err) + } + } + + schema := ` +CREATE TABLE IF NOT EXISTS samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts DATETIME NOT NULL, + sila_l_pct REAL NOT NULL, + sila_r_pct REAL NOT NULL, + sila_l_kn REAL NOT NULL, + sila_r_kn REAL NOT NULL, + sum_pct REAL NOT NULL, + sum_kn REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); +` + + if _, err := database.Exec(schema); err != nil { + _ = database.Close() + return nil, fmt.Errorf("create schema: %w", err) + } + + return database, nil +} + +func startDBWriter(database *sql.DB) { + ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond) + defer ticker.Stop() + + batch := make([]Sample, 0, cfg.DB.BatchSize) + + flush := func() { + if len(batch) == 0 { + return + } + + tx, err := database.Begin() + if err != nil { + log.Printf("db begin failed: %v", err) + return + } + + stmt, err := tx.Prepare(`INSERT INTO samples (ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, sum_pct, sum_kn) VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + _ = tx.Rollback() + log.Printf("db prepare failed: %v", err) + return + } + + ok := true + for _, s := range batch { + _, err := stmt.Exec( + s.TS.UTC().Format(time.RFC3339Nano), + s.SilaLPct, + s.SilaRPct, + s.SilaLKN, + s.SilaRKN, + s.SumPercent, + s.SumKN, + ) + if err != nil { + ok = false + log.Printf("db insert failed: %v", err) + break + } + } + + _ = stmt.Close() + + if !ok { + _ = tx.Rollback() + return + } + if err := tx.Commit(); err != nil { + log.Printf("db commit failed: %v", err) + return + } + + batch = batch[:0] + } + + for { + select { + case s := <-sampleCh: + batch = append(batch, s) + if len(batch) >= cfg.DB.BatchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func startDBCleanup(database *sql.DB) { + if cfg.DB.RetentionDays <= 0 { + return + } + + ticker := time.NewTicker(time.Duration(cfg.DB.CleanupIntervalHr) * time.Hour) + defer ticker.Stop() + + cleanup := func() { + cutoff := time.Now().AddDate(0, 0, -cfg.DB.RetentionDays).UTC().Format(time.RFC3339Nano) + if _, err := database.Exec(`DELETE FROM samples WHERE ts < ?`, cutoff); err != nil { + log.Printf("db cleanup failed: %v", err) + } + } + + cleanup() + + for range ticker.C { + cleanup() + } +} + +func startPLCPoller() { for { handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot) handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second @@ -363,7 +545,6 @@ func startPLCPoller() { for { buf := make([]byte, 8) - if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil { log.Printf("PLC read error: %v - reconnecting...", err) setDisconnected() @@ -376,9 +557,7 @@ func startPLCPoller() { silaR := helper.GetRealAt(buf, 4) leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR) - now := time.Now() - ts := now.Format("15:04:05.000") state.Lock() state.Connected = true @@ -389,61 +568,161 @@ func startPLCPoller() { state.SumPercent = sumPercent state.SumkN = sumKN state.LastUpdate = now - - state.History = append(state.History, Measurement{ - Time: ts, - SilaL: silaL, - SilaR: silaR, - }) - - if len(state.History) > maxHistoryPoints { - state.History = state.History[len(state.History)-maxHistoryPoints:] - } state.Unlock() + enqueueSample(Sample{ + TS: now, + SilaLPct: silaL, + SilaRPct: silaR, + SilaLKN: leftKN, + SilaRKN: rightKN, + SumPercent: sumPercent, + SumKN: sumKN, + }) + time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) } } } -// ============================================= -// HTTP Handlers -// ============================================= +func parseWindow(raw string) (time.Duration, string, error) { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + s = fmt.Sprintf("%dm", cfg.Trend.Minutes) + } + + if strings.HasSuffix(s, "d") { + n, err := strconv.Atoi(strings.TrimSuffix(s, "d")) + if err != nil || n <= 0 { + return 0, "", fmt.Errorf("invalid day window") + } + d := time.Duration(n) * 24 * time.Hour + return d, s, nil + } + + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return 0, "", fmt.Errorf("invalid window") + } + return d, s, nil +} + +func queryHistory(window time.Duration) ([]HistoryPoint, error) { + cutoff := time.Now().Add(-window).UTC().Format(time.RFC3339Nano) + + rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts >= ? ORDER BY ts ASC`, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + points := make([]HistoryPoint, 0, 1024) + for rows.Next() { + var ts string + var l, r float64 + if err := rows.Scan(&ts, &l, &r); err != nil { + return nil, err + } + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + continue + } + points = append(points, HistoryPoint{ + Time: t.Local().Format("15:04:05.000"), + SilaL: float32(l), + SilaR: float32(r), + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(points) <= cfg.DB.MaxChartPoints { + return points, nil + } + + sampled := downsamplePoints(points, cfg.DB.MaxChartPoints) + sort.Slice(sampled, func(i, j int) bool { return sampled[i].Time < sampled[j].Time }) + return sampled, nil +} + +func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint { + if len(points) <= max || max < 3 { + return points + } + out := make([]HistoryPoint, 0, max) + step := float64(len(points)-1) / float64(max-1) + used := make(map[int]struct{}, max) + + for i := 0; i < max; i++ { + idx := int(float64(i) * step) + if idx >= len(points) { + idx = len(points) - 1 + } + if _, ok := used[idx]; ok { + continue + } + used[idx] = struct{}{} + out = append(out, points[idx]) + } + + if out[len(out)-1].Time != points[len(points)-1].Time { + out[len(out)-1] = points[len(points)-1] + } + return out +} + func apiData(w http.ResponseWriter, r *http.Request) { - resp := snapshotState() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(snapshotState()) +} + +func apiHistory(w http.ResponseWriter, r *http.Request) { + window, label, err := parseWindow(r.URL.Query().Get("window")) + if err != nil { + http.Error(w, "invalid window", http.StatusBadRequest) + return + } + + points, err := queryHistory(window) + if err != nil { + http.Error(w, "history query failed", http.StatusInternalServerError) + log.Printf("history query failed: %v", err) + return + } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(HistoryResponse{ + Window: label, + Points: points, + }) } func serveUI(w http.ResponseWriter, r *http.Request) { data := PageData{ - Title: cfg.UI.Title, - Subtitle: cfg.UI.Subtitle, - LeftLabel: cfg.UI.LeftLabel, - RightLabel: cfg.UI.RightLabel, - UnitForce: cfg.UI.UnitForce, - UnitPct: cfg.UI.UnitPct, - MaxTonnage: cfg.Press.MAX_TONNAGE, - WarningPercent: cfg.Thresholds.WarningPercent, - CriticalPercent: cfg.Thresholds.CriticalPercent, - GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, - TrendMinutes: cfg.Trend.Minutes, - MaxHistoryPoints: getMaxHistoryPoints(cfg), - PollMs: cfg.PLC.PollMs, + Title: cfg.UI.Title, + Subtitle: cfg.UI.Subtitle, + LeftLabel: cfg.UI.LeftLabel, + RightLabel: cfg.UI.RightLabel, + UnitForce: cfg.UI.UnitForce, + UnitPct: cfg.UI.UnitPct, + MaxTonnage: cfg.Press.MAX_TONNAGE, + WarningPercent: cfg.Thresholds.WarningPercent, + CriticalPercent: cfg.Thresholds.CriticalPercent, + GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, + PollMs: cfg.PLC.PollMs, + DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes), } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := uiTmpl.Execute(w, data); err != nil { - http.Error(w, "failed to render UI", http.StatusInternalServerError) + if err := uiTemplate.Execute(w, data); err != nil { log.Printf("template execute error: %v", err) + http.Error(w, "render failed", http.StatusInternalServerError) } } -// ============================================= -// Main -// ============================================= func main() { wd, err := os.Getwd() if err != nil { @@ -451,30 +730,43 @@ func main() { } configPath := filepath.Join(wd, "config.yaml") - cfg, err = loadOrCreateConfig(configPath) if err != nil { log.Fatalf("failed to load config: %v", err) } + dbPath := cfg.DB.Path + if !filepath.IsAbs(dbPath) { + dbPath = filepath.Join(wd, dbPath) + } + + db, err = initDatabase(dbPath) + if err != nil { + log.Fatalf("failed to init database: %v", err) + } + defer db.Close() + + sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) + log.Printf("config loaded from: %s", configPath) + log.Printf("sqlite 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.MAX_TONNAGE, cfg.UI.UnitForce) + go startDBWriter(db) + go startDBCleanup(db) go startPLCPoller() http.HandleFunc("/", serveUI) http.HandleFunc("/api/data", apiData) + http.HandleFunc("/api/history", apiHistory) log.Println("S7-1200 Force Monitor started") log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) } -// ============================================= -// Embedded UI Template -// ============================================= const uiHTML = `
@@ -487,130 +779,111 @@ const uiHTML = ` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap'); :root { - --bg: #09090b; - --panel: rgba(255,255,255,0.06); - --panel-border: rgba(255,255,255,0.10); + --bg1: #050816; + --bg2: #0b1224; + --panel: rgba(255,255,255,0.055); + --line: rgba(255,255,255,0.09); --muted: #a1a1aa; - --line: #27272a; } * { box-sizing: border-box; } - body { font-family: 'Inter', system-ui, sans-serif; background: - radial-gradient(circle at top left, rgba(56,189,248,0.12), transparent 22%), - radial-gradient(circle at top right, rgba(168,85,247,0.12), transparent 22%), - linear-gradient(180deg, #09090b 0%, #0f172a 100%); + 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%); color: #f4f4f5; } - - .title { - font-family: 'Space Grotesk', sans-serif; - } - + .title { font-family: 'Space Grotesk', sans-serif; } .glass { - background: rgba(255,255,255,0.055); + background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); } - + .soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); } + .soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); } + .soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); } .gauge-container { position: relative; width: 100%; - max-width: 360px; + max-width: 420px; height: 340px; margin: 0 auto; } - .gauge-canvas { width: 100%; height: 100%; display: block; } - - .soft-glow-green { - box-shadow: 0 0 0 1px rgba(34,197,94,0.30), 0 0 40px rgba(34,197,94,0.08); + .window-btn.active { + border-color: rgba(34,211,238,0.9); + color: white; + background: rgba(34,211,238,0.14); + box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset; } - - .soft-glow-yellow { - box-shadow: 0 0 0 1px rgba(234,179,8,0.30), 0 0 40px rgba(234,179,8,0.08); - } - - .soft-glow-red { - box-shadow: 0 0 0 1px rgba(239,68,68,0.30), 0 0 40px rgba(239,68,68,0.08); - } - - .metric-big { - line-height: 0.95; - } - - .status-pill { - border: 1px solid rgba(255,255,255,0.08); - background: rgba(255,255,255,0.03); + .chart-wrap { + width: min(92vw, 1800px); + margin: 0 auto; } - -