From 539e43195ff45c4249b7b1916ebfd24574a858e7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 24 Apr 2026 15:46:47 +0300 Subject: [PATCH 01/11] feat(ui): Lumenworks studio-console WebUI redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-app UI/UX refresh committing to a tech-instrument / studio-console aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts. Design tokens and fonts: - Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders Display (numeric readouts) as local .woff2 variable fonts with latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range. - New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold), --lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/ -violet channel palette, --lux-signal-glow, --lux-shadow-rack, all theme-aware for dark + light. Existing tokens untouched for compat. Shell (header + sidebar): - Header rebuilt as a 3-column CSS-grid transport bar (brand | center | toolbar) with a glowing LED brand mark rendered via pseudo-elements on .header-title. Gradient channel-color rule under the bottom border. - New sidebar.css introduces a vertical channel-strip nav. Active tab gets a glowing left stripe + radial tint + LED pip. .sidebar-foot contains a live CPU/FPS meter plate. - Sidebar collapses to a 56 px icon rail at <=1100 px and hides via display:contents at <=600 px so mobile.css's fixed bottom tab-bar flows through unchanged. Cards and dashboard: - .card gets channel stripe (data-card-type + .ch-* utilities auto-map from data-target-id / data-stream-id / data-automation-id etc.), corner bracket, gradient background, subtle rack shadow. - .card-running replaces the old @property --border-angle conic-gradient rotating border with a lightweight signalFlow linear-gradient strip on the bottom edge (cheaper paint, no GPU layer compositing per card). - Skeleton loaders rewritten: left hairline + corner bracket + gradient shimmer instead of the old text-color opacity pulse. - .dashboard-target rows pick up the same channel-stripe + signalFlow treatment. Section headers use mono micro-caps with a channel-green underline accent consistent across the app. - .perf-chart-card: channel stripe replaces old border-top; per-metric accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green, temp=amber). Metric values use tabular-nums + a soft glow. Live bindings (no new endpoints): - _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing /system/performance poll. - _updateTransportStatus: toggles the transport chip between "Ready" and "Armed - N live" whenever the dashboard's running-target set is recomputed. Tree-nav + sub-tabs: - tree-nav.css trigger pill gets a channel-stripe left edge that glows when open; panel has a gradient channel-accent rule across the top; group headers use silkscreened micro-caps; active leaf has a pulsing LED pip + channel tint. - .stream-tab-btn / .subtab-section-header adopt the same mono-caps + channel-underline language for consistency. - Graph editor toolbar gets gradient + hairline + rack shadow + backdrop blur. Canvas and nodes untouched. Modals (40+ modals share modal.css): - Radial-dim + 6 px blur backdrop. Content gets a gradient background, hairline border, deep rack shadow, top channel-accent rule driven by --modal-ch, bottom-right corner bracket (hidden on mobile fullscreen). - Per-modal-ID channel lanes: target editors = green, source/input editors = cyan, audio = magenta, automation/scene/game = violet, settings/auth = amber, confirm = coral. - Modal headers: vertical channel stripe left of the title + hairline divider. Modal footers: hairline top border + subtle gradient wash. Forms: - Inputs use hairline borders; number inputs switch to mono + tabular-nums for column alignment. Focus state: channel-green ring + soft glow. - Buttons use mono-uppercase type with signal-glow on primary and coral- glow on danger. Mobile (<=600 px): - Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill, top channel-accent rule matching the transport bar, backdrop blur. Active tab has an LED pip above the icon + channel tint + icon recolor. - Fullscreen modals: corner bracket hidden, header stripe slimmed. Microcopy (en / ru / zh): - "Targets" -> "Channels" / "Каналы" / "通道" - "Sources" -> "Inputs" / "Входы" / "输入" - Internal tab keys (dashboard/automations/targets/streams/integrations/ graph) kept stable so no JS or localStorage migration is needed. - Added: sidebar.workspaces, sidebar.load, sidebar.fps, transport.status.ready, transport.status.armed. Compatibility: - All existing class hooks preserved (.tab-bar, .tab-btn, .card, .card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content, .dashboard-target, etc.). No JS or API changes required for the new look to take effect. - Tour selectors survive (header .header-title, #tab-btn-*, onclick markers on theme/settings/search, #cp-wrap-accent, etc.). - Mobile <=600 px bottom tab-bar keeps working via display:contents fall-through in the new sidebar. Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from ~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily per unicode-range subset (~98 KB critical path for English). Phased plan + deferred follow-ups (dashboard hero strip, legacy-token cleanup) recorded at the top of TODO.md. Reference mockup: server/docs/ui-redesign-mockup.html. --- TODO.md | 171 ++ server/docs/ui-redesign-mockup.html | 1378 +++++++++++++++++ server/package-lock.json | 48 + server/package.json | 3 + server/src/ledgrab/static/css/all.css | 1 + server/src/ledgrab/static/css/base.css | 75 +- server/src/ledgrab/static/css/cards.css | 280 ++-- server/src/ledgrab/static/css/components.css | 82 +- server/src/ledgrab/static/css/dashboard.css | 182 ++- server/src/ledgrab/static/css/fonts.css | 103 +- .../src/ledgrab/static/css/graph-editor.css | 12 +- server/src/ledgrab/static/css/layout.css | 234 ++- server/src/ledgrab/static/css/mobile.css | 118 +- server/src/ledgrab/static/css/modal.css | 135 +- server/src/ledgrab/static/css/sidebar.css | 305 ++++ server/src/ledgrab/static/css/streams.css | 68 +- server/src/ledgrab/static/css/tree-nav.css | 198 ++- .../big-shoulders-display-latin-ext.woff2 | Bin 0 -> 28688 bytes .../fonts/big-shoulders-display-latin.woff2 | Bin 0 -> 35504 bytes .../fonts/jetbrains-mono-cyrillic-ext.woff2 | Bin 0 -> 2028 bytes .../fonts/jetbrains-mono-cyrillic.woff2 | Bin 0 -> 12108 bytes .../fonts/jetbrains-mono-latin-ext.woff2 | Bin 0 -> 15196 bytes .../static/fonts/jetbrains-mono-latin.woff2 | Bin 0 -> 40404 bytes .../static/fonts/manrope-cyrillic-ext.woff2 | Bin 0 -> 2552 bytes .../static/fonts/manrope-cyrillic.woff2 | Bin 0 -> 14500 bytes .../static/fonts/manrope-latin-ext.woff2 | Bin 0 -> 15120 bytes .../ledgrab/static/fonts/manrope-latin.woff2 | Bin 0 -> 24836 bytes .../ledgrab/static/js/features/dashboard.ts | 19 + .../ledgrab/static/js/features/perf-charts.ts | 16 + server/src/ledgrab/static/locales/en.json | 11 +- server/src/ledgrab/static/locales/ru.json | 11 +- server/src/ledgrab/static/locales/zh.json | 11 +- server/src/ledgrab/templates/index.html | 42 +- 33 files changed, 3145 insertions(+), 358 deletions(-) create mode 100644 server/docs/ui-redesign-mockup.html create mode 100644 server/src/ledgrab/static/css/sidebar.css create mode 100644 server/src/ledgrab/static/fonts/big-shoulders-display-latin-ext.woff2 create mode 100644 server/src/ledgrab/static/fonts/big-shoulders-display-latin.woff2 create mode 100644 server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic-ext.woff2 create mode 100644 server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic.woff2 create mode 100644 server/src/ledgrab/static/fonts/jetbrains-mono-latin-ext.woff2 create mode 100644 server/src/ledgrab/static/fonts/jetbrains-mono-latin.woff2 create mode 100644 server/src/ledgrab/static/fonts/manrope-cyrillic-ext.woff2 create mode 100644 server/src/ledgrab/static/fonts/manrope-cyrillic.woff2 create mode 100644 server/src/ledgrab/static/fonts/manrope-latin-ext.woff2 create mode 100644 server/src/ledgrab/static/fonts/manrope-latin.woff2 diff --git a/TODO.md b/TODO.md index d02a243..6710b64 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,176 @@ # LedGrab TODO +## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic + +Full-app UI/UX refresh. Design direction committed to by user 2026-04-24. +Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html). +Phases are independent and CSS-only where possible — backend untouched. + +### Phase 1 — Design tokens & font embed + +- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`): + Manrope (latin + latin-ext + cyrillic + cyrillic-ext), + JetBrains Mono (same 4 subsets), + Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped, + served via `unicode-range` so only latin paints on first load. +- [x] `fonts.css` — declare `@font-face` entries for all new families with + proper `unicode-range` subsetting; keep DM Sans + Orbitron registered + for legacy-token callers during migration. +- [x] `base.css` — add additive Lumenworks tokens: + `--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`, + `--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]` + define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`, + `--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`, + `--lux-shadow-rack`. Existing tokens untouched — no visual regression. + +### Phase 2 — Shell (header → transport bar + channel-strip sidebar) + +- [x] `index.html` — `.tab-bar` moved out of `
` into a new + `
@@ -534,6 +544,67 @@ // Initialize on load updateAuthUI(); + // Transport-bar session uptime ticker — time since page load. + (function() { + const pageLoadedAt = Date.now(); + const el = document.getElementById('transport-uptime'); + if (!el) return; + function pad(n) { return n < 10 ? '0' + n : String(n); } + function render() { + const secs = Math.floor((Date.now() - pageLoadedAt) / 1000); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + el.textContent = `${pad(h)}:${pad(m)}:${pad(s)}`; + } + render(); + setInterval(render, 1000); + })(); + + // Transport-bar poll-interval control — cycles through 1/2/5/10s + // presets on click. Affects dashboard refresh + perf polling, so + // it belongs in the global transport bar rather than the Dashboard + // toolbar. + (function() { + const PRESETS = [1000, 2000, 5000, 10000]; + const KEY = 'dashboard_poll_interval'; + const root = document.getElementById('transport-poll'); + const valEl = document.getElementById('transport-poll-value'); + if (!root || !valEl) return; + + function render(ms) { + const s = Math.round(ms / 1000); + valEl.textContent = `${s}s`; + } + + function apply(ms) { + localStorage.setItem(KEY, String(ms)); + render(ms); + // Call the existing global hook if loaded (it also restarts + // auto-refresh + perf polling with the new interval). + if (typeof window.changeDashboardPollInterval === 'function') { + window.changeDashboardPollInterval(String(Math.round(ms / 1000))); + } + } + + render(parseInt(localStorage.getItem(KEY), 10) || 2000); + + function cycle(dir) { + const cur = parseInt(localStorage.getItem(KEY), 10) || 2000; + let idx = PRESETS.indexOf(cur); + if (idx < 0) idx = 1; // default to 2s if unknown + idx = (idx + (dir || 1) + PRESETS.length) % PRESETS.length; + apply(PRESETS[idx]); + } + + root.addEventListener('click', function(e) { e.stopPropagation(); cycle(1); }); + root.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cycle(1); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); cycle(1); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); cycle(-1); } + }); + })(); + // Modal functions function togglePasswordVisibility() { const input = document.getElementById('api-key-input'); diff --git a/server/src/ledgrab/utils/metrics/psutil_provider.py b/server/src/ledgrab/utils/metrics/psutil_provider.py index facaf52..7aa8980 100644 --- a/server/src/ledgrab/utils/metrics/psutil_provider.py +++ b/server/src/ledgrab/utils/metrics/psutil_provider.py @@ -3,6 +3,11 @@ from __future__ import annotations import os +import platform +import subprocess +import threading +import time +from typing import Optional from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot @@ -24,6 +29,14 @@ class PsutilMetricsProvider: self._process = psutil_module.Process(os.getpid()) self._process.cpu_percent(interval=None) self._cpu_count = int(psutil_module.cpu_count(logical=True) or 1) + # psutil has no sensors_temperatures() on Windows, so fall back to a + # throttled WMI/LHM reader running in a daemon thread. Disabled in + # tests via LEDGRAB_DISABLE_WIN_TEMP. + self._windows_temp: Optional[_WindowsCpuTemp] = ( + _WindowsCpuTemp() + if platform.system() == "Windows" and not os.environ.get("LEDGRAB_DISABLE_WIN_TEMP") + else None + ) def cpu_percent(self) -> float: return float(self._psutil.cpu_percent(interval=None)) @@ -80,8 +93,137 @@ class PsutilMetricsProvider: except Exception: pass + # Windows fallback: psutil exposes no CPU temperature there, so the + # reading would always be None without this. Other platforms keep + # the psutil result as-is. + if cpu_temp is None and self._windows_temp is not None: + cpu_temp = self._windows_temp.get() + return ThermalSnapshot( battery_percent=battery_pct, battery_temp_c=battery_temp, cpu_temp_c=cpu_temp, ) + + +# ── Windows CPU temperature helper ─────────────────────────────────────── + +# Windows has no user-space API for real per-core CPU temperature without +# a vendor driver or third-party monitoring service, so we only try sources +# that reflect the actual CPU die rather than a motherboard/chassis zone: +# +# 1. LibreHardwareMonitor / OpenHardwareMonitor WMI — °C. Only usable when +# the monitoring app is running, but reads Intel DTS / AMD SMN directly +# so the reading actually tracks load. +# 2. ``MSAcpi_ThermalZoneTemperature`` WMI — Kelvin × 10. Some OEM boards +# wire this to the CPU; many require admin or expose a chassis zone +# instead. Only used as a last resort. +# +# The ``\Thermal Zone Information(*)\Temperature`` perf counter is +# deliberately NOT queried: on most consumer desktops it returns ACPI +# TZxx zones that are pinned at ~27–30 °C regardless of CPU load — a +# misleading stable reading is worse than no reading at all. +# +# Emits a single numeric line on stdout and exits. +_WIN_TEMP_POWERSHELL = ( + "$ErrorActionPreference='SilentlyContinue';" + "foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {" + " $lhm = Get-CimInstance -Namespace $ns -ClassName Sensor" + " -Filter \"SensorType='Temperature'\";" + " if ($lhm) {" + " $cpu = $lhm | Where-Object { $_.Parent -match 'cpu' -or $_.Name -match 'CPU' }" + " | Sort-Object Value -Descending | Select-Object -First 1;" + " if ($cpu) { '{0:N2}' -f $cpu.Value; exit }" + " }" + "}" + "$acpi = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature;" + "if ($acpi) {" + " $t = ($acpi | Measure-Object -Property CurrentTemperature -Maximum).Maximum;" + " if ($t) { '{0:N2}' -f ($t / 10.0 - 273.15); exit }" + "}" +) + + +def _query_windows_cpu_temp() -> Optional[float]: + """Run the PowerShell WMI probe once and parse the single-line result. + + Returns None on any failure. Rejects wildly out-of-range values to + guard against sensors that report raw (un-scaled) Kelvin or 0. + """ + if platform.system() != "Windows": + return None + try: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", _WIN_TEMP_POWERSHELL], + capture_output=True, + text=True, + timeout=4.0, + creationflags=creationflags, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + line = (result.stdout or "").strip().splitlines() + if not line: + return None + try: + # Locale may use comma as decimal separator (e.g. ru-RU). + temp = float(line[0].replace(",", ".").strip()) + except ValueError: + return None + if -20.0 <= temp <= 150.0: + return temp + return None + + +class _WindowsCpuTemp: + """Throttled background reader for Windows CPU temperature. + + Spawning PowerShell costs hundreds of ms per call, so we refresh in a + daemon thread at most once every ``REFRESH_INTERVAL_S`` seconds and + return the most recent cached value from ``get()``. After + ``MAX_FAILURES`` consecutive empty results we self-disable to avoid + launching PowerShell forever on hosts without any usable sensor. + """ + + REFRESH_INTERVAL_S = 5.0 + MAX_FAILURES = 3 + + def __init__(self) -> None: + self._cached_c: Optional[float] = None + self._last_refresh: float = 0.0 + self._refreshing: bool = False + self._disabled: bool = False + self._failures: int = 0 + self._lock = threading.Lock() + + def get(self) -> Optional[float]: + if self._disabled: + return None + now = time.monotonic() + with self._lock: + due = now - self._last_refresh >= self.REFRESH_INTERVAL_S + should_start = due and not self._refreshing + if should_start: + self._refreshing = True + if should_start: + threading.Thread(target=self._refresh, daemon=True).start() + return self._cached_c + + def _refresh(self) -> None: + try: + value = _query_windows_cpu_temp() + finally: + now = time.monotonic() + with self._lock: + self._last_refresh = now + self._refreshing = False + if value is not None: + self._cached_c = value + self._failures = 0 + else: + self._failures += 1 + if self._failures >= self.MAX_FAILURES: + self._disabled = True diff --git a/server/tests/test_metrics_provider.py b/server/tests/test_metrics_provider.py index bb9e25e..62d52d8 100644 --- a/server/tests/test_metrics_provider.py +++ b/server/tests/test_metrics_provider.py @@ -21,7 +21,10 @@ from ledgrab.utils.metrics import android_provider as android_mod @pytest.fixture(autouse=True) -def _reset_provider_cache(): +def _reset_provider_cache(monkeypatch): + # Disable the Windows CPU-temp background reader so tests don't spawn + # PowerShell when run on a Windows host. + monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1") reset_metrics_provider() yield reset_metrics_provider() From 70c95d1c099093e7405604462bb28fa41bc36a2b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 24 Apr 2026 21:59:30 +0300 Subject: [PATCH 03/11] feat(ui): item-card restyle, perf hover tooltips, FPS ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item cards (Automations, Channels, Inputs, Integrations): - `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink for better presence against the flat card bg. - `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter gap so rule chips pack in a readable row. - `.stream-card-prop` rule chips — rectangular 2px radius + hairline border + flat dark bg (was rounded 10px grey pill). Channel-signal icon tint; hover fades in a channel-green wash with matching border. - `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em tracking, hairline border slot for variants. - `.badge-automation-active` — channel-signal tinted bg + border + soft outer glow so the "ACTIVE" state reads at a glance. - `.badge-automation-inactive` / `-disabled` — transparent with a hairline outline so they sit quietly alongside the active variant. - `.device-url-badge` — switched from rounded pill to rectangular hairline mono chip; hover shifts to filled bg + bolder border + brighter ink. - `.card-actions` — 1px hairline top divider, 6px gap. - `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel- signal glow on hover (replaces the old scale(1.1) jiggle). - `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow (drives the "disable" action in the automation card). - `.btn-icon.btn-success` — signal-green ink + hairline + green hover glow ("enable" action). Cross-link navigation highlight: - `cardHighlight` keyframes were using an undefined `--primary-rgb` var, so the outer glow fell back to 59/130/246 (the Tailwind blue default). Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks the accent picker and reads as signal-green. Added double-layer box-shadow (ring + 32px/10px bloom) so the highlight is obvious on the flat dark/light card surfaces. Added .dashboard-target to the selector + `isolation: isolate` so the glow isn't clipped inside overflow: hidden containers (perf strip cells, tree-nav panels). Perf strip (follow-up polish): - Total FPS cell shows `/` ceiling suffix next to the live value — sum of fps_target across running targets, styled like the Patches "/12". A dashed horizontal reference line at that ceiling is rendered on the sparkline so the live value reads as "percentage of max achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so the dashed line never clips. - Removed the empty `.perf-chart-app` pill in the FPS cell (no app variant). Added `:empty { display: none }` as a safety so any other unpopulated cell doesn't render a ghost pill. - Hover tooltips on all sparks — single floating `.perf-chart-tooltip` in with fixed positioning; event-delegated from the perf grid so re-renders don't need rebinding. Shows metric label + sys value + app value (in both-mode) + "−Ns ago" age line derived from the poll interval. Vertical marker line follows the cursor over the spark; `cursor: crosshair` on the spark container signals interact- ability. `pointer-events: none` shifted from the spark container down to the inner SVG so hover events land on the container. Grid: - Perf strip capped at 4 cols even on widescreen; wraps to 2 rows × 4 when the full 7 cells are present. Responsive breakpoints at 1100 / 760 / 480 px. - Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap; overflow: hidden; text-overflow: clip` prevents mid-text wrapping. - `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines align across cells regardless of whether a subtitle is present (CPU/GPU model name, FPS min/max). Dashboard target meta: - Integrations card stripe reverted to the default signal color so it matches the overall accent picker; the health-dot inside the card carries the connection state. Removed the per-integration channel override in both cards.css and dashboard.css. Section headers: - `.dashboard-section-header` / `.subtab-section-header` underline switched from dashed to solid; channel-green 40px accent rule on the left remains. - Section count badge (`.dashboard-section-count`) restyled to match the rest of the badge family (mono tabular-nums, 2px radius, hairline border, --lux-bg-3 fill). Build: tsc --noEmit clean; CSS bundle stable at ~216 KB. --- server/src/ledgrab/static/css/automations.css | 18 +- server/src/ledgrab/static/css/cards.css | 42 +++-- server/src/ledgrab/static/css/components.css | 52 +++++- server/src/ledgrab/static/css/dashboard.css | 108 +++++++++++- server/src/ledgrab/static/css/patterns.css | 58 +++++-- server/src/ledgrab/static/css/streams.css | 14 +- .../ledgrab/static/js/features/dashboard.ts | 10 +- .../ledgrab/static/js/features/perf-charts.ts | 162 +++++++++++++++++- 8 files changed, 400 insertions(+), 64 deletions(-) diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index 3b3a909..f284250 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -1,19 +1,23 @@ /* ===== AUTOMATIONS ===== */ .badge-automation-active { - background: var(--success-color); - color: #fff; + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); + color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); } .badge-automation-inactive { - background: var(--border-color); - color: var(--text-color); + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-dim, var(--text-color)); } .badge-automation-disabled { - background: var(--border-color); - color: var(--text-muted); - opacity: 0.7; + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); + opacity: 0.8; } .automation-status-disabled { diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 4322bde..789af28 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -582,18 +582,21 @@ body.cs-drag-active .card-drag-handle { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 10px; + margin-bottom: 12px; padding-right: 60px; } .card-title { + font-family: var(--font-body, inherit); font-size: 1.05rem; - font-weight: 600; + font-weight: 700; + letter-spacing: -0.01em; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; + color: var(--lux-ink, var(--text-color)); } .card-title-text { @@ -613,17 +616,18 @@ body.cs-drag-active .card-drag-handle { .device-url-badge { display: inline-flex; align-items: center; - gap: 4px; - font-size: 0.7rem; - font-weight: 400; - color: var(--text-secondary); - background: var(--border-color); + gap: 5px; + font-size: 0.68rem; + font-weight: 500; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); padding: 2px 8px; - border-radius: 10px; - letter-spacing: 0.03em; - font-family: monospace; + border-radius: 2px; + letter-spacing: 0.04em; + font-family: var(--font-mono, monospace); text-decoration: none; - transition: background 0.2s; + transition: background 0.2s, border-color 0.2s, color 0.2s; white-space: nowrap; flex-shrink: 1; overflow: hidden; @@ -636,7 +640,9 @@ body.cs-drag-active .card-drag-handle { } .device-url-badge:hover { - background: var(--text-muted); + background: var(--lux-bg-2, var(--text-muted)); + border-color: var(--lux-line-bold, var(--border-color)); + color: var(--lux-ink, var(--text-color)); } .device-url-icon { @@ -650,17 +656,19 @@ body.cs-drag-active .card-drag-handle { .card-subtitle { display: flex; align-items: center; - gap: 12px; - margin-bottom: 15px; + gap: 8px; + margin-bottom: 14px; flex-wrap: wrap; } .card-meta { - font-size: 0.8rem; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--lux-ink-mute, var(--text-secondary)); display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + letter-spacing: 0.04em; } .card-meta .icon { diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 3ed35e5..c9dc248 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -23,16 +23,17 @@ .card-actions { display: flex; - gap: 8px; + gap: 6px; margin-top: auto; padding-top: 12px; - border-top: 1px solid var(--border-color); + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); align-items: center; } .card-actions .btn-icon { - padding: 6px 8px; - font-size: 1.1rem; + padding: 7px 10px; + min-width: 36px; + font-size: 0.95rem; } .btn { @@ -95,14 +96,51 @@ .btn-icon { min-width: auto; - padding: 8px 12px; - font-size: 1.2rem; + padding: 7px 10px; + font-size: 1rem; flex: 0 0 auto; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + background: transparent; + color: var(--lux-ink-dim, var(--text-color)); + transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s; } .btn-icon:hover { - transform: scale(1.1); + transform: none; opacity: 1; + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color))); + filter: none; + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); +} + +/* Variant: warning / success for enable/disable action buttons. Keep + flat hairline borders; just shift the color + hover glow. */ +.btn-icon.btn-warning { + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-warning:hover { + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent); + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent); +} + +.btn-icon.btn-success { + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-success:hover { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent); + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent); + box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); } .btn-icon:active:not(:disabled) { diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 82ee954..41573b4 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -961,6 +961,91 @@ line-height: 1.1; } +/* Hide the pill when there's nothing to show (host-only metrics, or a + mode/state that has no app variant). Avoids a ghost bordered box in + the top-right corner. */ +.perf-chart-app:empty { display: none; } + +/* ── Spark hover tooltip (single floating element, reused across all + cards; positioned via JS; inline layout reads like an instrument + readout). ── */ +.perf-chart-tooltip { + position: fixed; + display: none; + z-index: var(--z-toast, 3500); + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 8px 24px rgba(0, 0, 0, 0.4); + padding: 8px 10px 6px; + pointer-events: none; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + color: var(--lux-ink, var(--text-color)); + letter-spacing: 0.02em; + line-height: 1.3; + min-width: 110px; +} + +.perf-chart-tooltip .perf-tip-row { + display: flex; + align-items: center; + gap: 8px; +} +.perf-chart-tooltip .perf-tip-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + box-shadow: 0 0 4px currentColor; +} +.perf-chart-tooltip .perf-tip-dot-app { + background: transparent; + border: var(--lux-hairline, 1px) solid currentColor; + box-shadow: none; +} +.perf-chart-tooltip .perf-tip-k { + color: var(--lux-ink-mute, var(--text-secondary)); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-right: auto; +} +.perf-chart-tooltip .perf-tip-v { + color: var(--lux-ink, var(--text-color)); + font-variant-numeric: tabular-nums; + font-weight: 700; +} +.perf-chart-tooltip .perf-tip-app .perf-tip-v { + color: var(--lux-ink-dim, var(--text-secondary)); + font-weight: 500; +} +.perf-chart-tooltip .perf-tip-age { + margin-top: 4px; + padding-top: 4px; + border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + font-size: 0.6rem; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.14em; + text-transform: uppercase; + text-align: right; +} + +/* Vertical marker line over the spark at cursor x. */ +.perf-chart-tooltip-marker { + position: fixed; + display: none; + width: 1px; + pointer-events: none; + background: var(--marker-color, var(--ch-signal, var(--primary-color))); + opacity: 0.55; + z-index: calc(var(--z-toast, 3500) - 1); + box-shadow: 0 0 6px var(--marker-color, var(--ch-signal, var(--primary-color))); +} + /* Hide the idle corner bracket on perf cards — the APP tag now owns that slot in 'both' mode. */ .perf-chart-card::after { @@ -1015,16 +1100,22 @@ bottom. `margin-top: auto` pushes it to the bottom so the spark baseline aligns across cells regardless of subtitle presence — cells with CPU/GPU model names, FPS min/max etc. no longer have a - higher spark than cells without a subtitle. */ + higher spark than cells without a subtitle. + Pointer events stay enabled so hover tooltips work; the SVG itself + is non-interactive via `perf-chart-svg` below. */ .perf-chart-spark { position: relative; margin-top: auto; height: 42px; padding: 0 18px 14px; - pointer-events: none; + cursor: crosshair; filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent)); } +.perf-chart-spark .perf-chart-svg { + pointer-events: none; +} + .perf-chart-spark .perf-chart-svg { width: 100%; height: 100%; @@ -1214,6 +1305,19 @@ align-self: center; } +/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized + down + muted so the live value remains the primary reading. Matches + the "/ 12" style from the Active Patches cell. */ +.perf-chart-card[data-metric="fps"] .perf-fps-ceiling { + font-family: var(--font-mono, monospace); + font-size: 0.38em; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.04em; + margin-left: 4px; + align-self: center; +} + /* Hint mode — the card is revealed with an explanatory message instead of a live metric (e.g. Windows without LibreHardwareMonitor for CPU temp). Neutralizes the big display font + hides the sparkline so the diff --git a/server/src/ledgrab/static/css/patterns.css b/server/src/ledgrab/static/css/patterns.css index 5ecc5b2..5d483a8 100644 --- a/server/src/ledgrab/static/css/patterns.css +++ b/server/src/ledgrab/static/css/patterns.css @@ -42,21 +42,30 @@ } .stream-card-prop { - display: inline-block; - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--border-color); - padding: 2px 8px; - border-radius: 10px; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono, monospace); + font-size: 0.68rem; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 3px 8px; + border-radius: 2px; + letter-spacing: 0.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 180px; + max-width: 220px; vertical-align: middle; + line-height: 1.3; } .stream-card-prop .icon { - color: var(--primary-text-color); + color: var(--ch-signal, var(--primary-color)); + width: 11px; + height: 11px; + flex-shrink: 0; } .stream-card-prop-full { @@ -65,18 +74,19 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 0.7rem; + font-size: 0.66rem; } .stream-card-link { cursor: pointer; text-decoration: none; - transition: background 0.2s, color 0.2s; + transition: background 0.2s, color 0.2s, border-color 0.2s; } .stream-card-link:hover { - background: var(--primary-color); - color: var(--primary-contrast); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent); + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color))); } .stream-card-link:hover .icon { @@ -84,15 +94,31 @@ } @keyframes cardHighlight { - 0%, 100% { box-shadow: none; } - 25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); } + 0%, 100% { + box-shadow: + 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent), + 0 0 0 0 transparent; + } + 25%, 75% { + box-shadow: + 0 0 0 2px var(--ch-signal, var(--primary-color)), + 0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent), + 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent); + } } .card-highlight, -.template-card.card-highlight { - animation: cardHighlight 2s ease-in-out; +.template-card.card-highlight, +.dashboard-target.card-highlight { + animation: cardHighlight 2.2s ease-in-out; position: relative; z-index: 11; + /* Nudge the card forward during the highlight so the outer glow + isn't clipped by a containing overflow: hidden (strip cells, + tree-nav panels). Box-shadow is never clipped by the element's + own overflow but *is* clipped by parent overflow in stacking + contexts where the card doesn't escape. */ + isolation: isolate; } /* Dim overlay behind highlighted card */ diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 95c8e4a..f565b74 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -93,13 +93,19 @@ } .badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: bold; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 2px; + border: var(--lux-hairline, 1px) solid transparent; + font-family: var(--font-mono, inherit); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; + line-height: 1.4; } .template-description { diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 756d7c7..0bc0469 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -599,9 +599,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) fpsTargetSum += tgt; } const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null; const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null; - updateTotalFps(fpsSum, fpsMin, fpsMax); + updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum); // Check if we can do an in-place metrics update (same targets, not first load) const newRunningIds = running.map(t => t.id).sort().join(','); diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 0edb0ef..4dd7d83 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -51,6 +51,9 @@ let _appHistory: Record = { cpu: [], ram: [], gpu: [], temp: [ /** Peak FPS observed during the session — used as the y-axis ceiling for * the FPS sparkline so slow targets look proportional to fast ones. */ let _fpsPeak = 60; +/** Sum of fps_target across running targets — rendered as a dashed + * reference line on the FPS spark ("max achievable throughput"). */ +let _fpsTargetSum = 0; let _hasGpu: boolean | null = null; let _hasTemp: boolean | null = null; let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'; @@ -156,7 +159,6 @@ export function renderPerfSection(): string {
${t('dashboard.perf.total_fps') || 'Total FPS'} -
@@ -234,16 +236,28 @@ function escapeText(s: string): string { /** Total FPS cell — pushed a new sample each dashboard refresh cycle. * `totalFps` is the sum of fps_actual across running targets; `minFps` - * / `maxFps` are the live extremes shown as a subdued subtitle. */ -export function updateTotalFps(totalFps: number, minFps: number | null, maxFps: number | null): void { + * / `maxFps` are the live extremes shown as a subdued subtitle; + * `targetSum` is the sum of each running target's fps_target and is + * drawn as a dashed "max" reference line on the spark. */ +export function updateTotalFps( + totalFps: number, + minFps: number | null, + maxFps: number | null, + targetSum: number = 0, +): void { const fps = Math.max(0, totalFps); _history.fps.push(fps); if (_history.fps.length > MAX_SAMPLES) _history.fps.shift(); if (fps > _fpsPeak) _fpsPeak = fps; + _fpsTargetSum = Math.max(0, targetSum || 0); const valEl = document.getElementById('perf-fps-value'); if (valEl) { - valEl.innerHTML = `${fps.toFixed(fps < 10 ? 1 : 0)}fps`; + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + const ceilingSuffix = _fpsTargetSum > 0 + ? `/ ${Math.round(_fpsTargetSum)}` + : ''; + valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; } const subEl = document.getElementById('perf-fps-sub'); if (subEl) { @@ -310,15 +324,25 @@ function _renderChartSvg(key: string): void { const showSystem = _mode === 'system' || _mode === 'both'; const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both'); - // Scale y per metric — temp varies 20..90°C; fps uses a session peak - // with a 60 floor so a 30 FPS signal fills ~half the cell; others - // are 0..100 %. + // Scale y per metric — temp varies 20..90°C; fps uses whichever is + // larger of the session peak or the target-sum ceiling with some + // headroom; others are 0..100 %. const yMin = key === 'temp' ? 20 : 0; const yMax = key === 'temp' ? 100 - : key === 'fps' ? Math.max(60, _fpsPeak * 1.1) + : key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1) : 100; const paths: string[] = []; + + // FPS-only: dashed "target ceiling" reference line at the sum of + // fps_target across running targets, so the spark reads as "live + // throughput relative to max achievable." + if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) { + const span = yMax - yMin || 1; + const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1; + paths.push(``); + } + if (showSystem && sys.length > 1) { paths.push(_pathFor(sys, yMin, yMax, color, 'sys')); } @@ -585,9 +609,129 @@ async function _seedFromServer(): Promise { } } -/** Initialize perf section — paint from server-side history. */ +/** Initialize perf section — paint from server-side history and wire up + * spark hover tooltips. */ export async function initPerfCharts(): Promise { await _seedFromServer(); + _initSparkTooltip(); +} + +// ─── Spark hover tooltip ───────────────────────────────────────── + +/** Single shared tooltip + marker element lazy-created on first hover. */ +let _tooltipEl: HTMLDivElement | null = null; +let _tooltipMarkerEl: HTMLDivElement | null = null; + +function _ensureTooltip(): HTMLDivElement { + if (_tooltipEl) return _tooltipEl; + const el = document.createElement('div'); + el.className = 'perf-chart-tooltip'; + el.setAttribute('aria-hidden', 'true'); + document.body.appendChild(el); + _tooltipEl = el; + const marker = document.createElement('div'); + marker.className = 'perf-chart-tooltip-marker'; + marker.setAttribute('aria-hidden', 'true'); + document.body.appendChild(marker); + _tooltipMarkerEl = marker; + return el; +} + +/** Format a sampled value per metric for the tooltip line. */ +function _formatSampleValue(key: string, v: number): string { + if (key === 'temp') return `${v.toFixed(1)}°C`; + if (key === 'fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`; + return `${v.toFixed(1)}%`; +} + +function _metricLabel(key: string): string { + if (key === 'cpu') return 'CPU'; + if (key === 'ram') return 'RAM'; + if (key === 'gpu') return 'GPU'; + if (key === 'temp') return 'Temp'; + if (key === 'fps') return 'Total FPS'; + return key.toUpperCase(); +} + +function _initSparkTooltip(): void { + const intervalMs = dashboardPollInterval || 2000; + // Event-delegate from .perf-charts-grid so re-renders of the perf + // section don't require re-binding per spark. + const grid = document.querySelector('.perf-charts-grid'); + if (!grid) return; + + grid.addEventListener('mousemove', (rawEv) => { + const ev = rawEv as MouseEvent; + const target = ev.target as HTMLElement; + const spark = target.closest('.perf-chart-spark') as HTMLElement | null; + if (!spark) { _hideTooltip(); return; } + const card = spark.closest('.perf-chart-card') as HTMLElement | null; + if (!card) { _hideTooltip(); return; } + const key = card.dataset.metric; + if (!key || !_history[key]) { _hideTooltip(); return; } + + const rect = spark.getBoundingClientRect(); + const sys = _history[key]; + const app = _appHistory[key]; + if (sys.length < 2) { _hideTooltip(); return; } + + // Samples right-align in the spark (new tick arrives at the right + // edge), so cursor x → index in the last-N window. + const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left)); + const fraction = rect.width > 0 ? relX / rect.width : 0; + // The visible series maps to the rightmost sys.length samples in + // a MAX_SAMPLES-wide viewBox — compute which actual sample the + // cursor x corresponds to. + const visibleStart = MAX_SAMPLES - sys.length; + const globalIdx = Math.round(fraction * (MAX_SAMPLES - 1)); + const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart)); + const sysValue = sys[localIdx]; + const appValue = app && app.length > localIdx ? app[localIdx] : null; + + // "-Ns ago" based on sample age (newest is rightmost). + const ageSecs = Math.round((sys.length - 1 - localIdx) * (intervalMs / 1000)); + + const tip = _ensureTooltip(); + const color = _getColor(key); + const sysLine = ` + ${_metricLabel(key)} + ${_formatSampleValue(key, sysValue)}`; + const appLine = (appValue != null) + ? `
+ + App + ${_formatSampleValue(key, appValue)} +
` + : ''; + const ageLine = `
${ageSecs === 0 ? 'now' : `−${ageSecs}s`}
`; + tip.innerHTML = `
${sysLine}
${appLine}${ageLine}`; + tip.style.display = 'block'; + + // Position tip above cursor, clamped to viewport. + const tipRect = tip.getBoundingClientRect(); + let tipLeft = ev.clientX - tipRect.width / 2; + let tipTop = rect.top - tipRect.height - 10; + if (tipTop < 6) tipTop = rect.bottom + 10; // flip below if no room above + tipLeft = Math.max(6, Math.min(window.innerWidth - tipRect.width - 6, tipLeft)); + tip.style.left = `${tipLeft}px`; + tip.style.top = `${tipTop}px`; + + // Vertical marker line over the spark at cursor x. + if (_tooltipMarkerEl) { + _tooltipMarkerEl.style.display = 'block'; + _tooltipMarkerEl.style.left = `${ev.clientX}px`; + _tooltipMarkerEl.style.top = `${rect.top}px`; + _tooltipMarkerEl.style.height = `${rect.height}px`; + _tooltipMarkerEl.style.setProperty('--marker-color', color); + } + }); + + grid.addEventListener('mouseleave', _hideTooltip); +} + +function _hideTooltip(): void { + if (_tooltipEl) _tooltipEl.style.display = 'none'; + if (_tooltipMarkerEl) _tooltipMarkerEl.style.display = 'none'; } export function startPerfPolling(): void { From 56853b71237f80d3fa864513b43ef1483f14f670 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 01:43:14 +0300 Subject: [PATCH 04/11] feat(dashboard): per-account customizable dashboard with slide-in panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-registry section/perf-cell schema persisted server-side under db.get_setting('dashboard_layout'); localStorage cache for instant first-paint, server sync after auth. 5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import. Slide-in Customize panel toggles section + perf-cell visibility, reorders via hand-rolled HTML5 drag (with up/down buttons for keyboard/TV-remote use), changes density per section, and exposes global Width / Animations / Perf-mode / Window with per-cell Inherit overrides. Window setting now drives the actual sparkline slice (30s/1m/2m/5m at configurable poll interval) instead of always rendering 120 fixed samples. Perf-grid edits re-render in place — sparklines repaint from persistent module-level history, value labels replay from cached last-fetch payload, so there is no flicker frame and no zero-data window between layout change and next poll. initPerfCharts now fires an immediate fetch on init so reload no longer shows "—" until the first interval tick. Reset confirmation uses the project's themed showConfirm modal instead of the browser dialog. Reserved registry keys (audio-meters, alerts, led-preview, source-thumbs, pinned, flow) are forward- compatible so v1.1 cards slot in without a schema bump. Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout treating the body as opaque JSON with a numeric version gate; covered by 6 round-trip / validation / unknown-field tests. --- TODO.md | 46 ++ server/src/ledgrab/api/__init__.py | 2 + server/src/ledgrab/api/routes/preferences.py | 75 +++ server/src/ledgrab/static/css/all.css | 1 + .../static/css/dashboard-customize.css | 517 ++++++++++++++++ server/src/ledgrab/static/js/app.ts | 18 + .../static/js/features/dashboard-customize.ts | 576 +++++++++++++++++ .../static/js/features/dashboard-layout.ts | 579 ++++++++++++++++++ .../ledgrab/static/js/features/dashboard.ts | 177 +++++- .../ledgrab/static/js/features/perf-charts.ts | 203 +++++- server/src/ledgrab/static/locales/en.json | 49 ++ server/src/ledgrab/static/locales/ru.json | 49 ++ server/src/ledgrab/static/locales/zh.json | 49 ++ server/tests/test_preferences_api.py | 145 +++++ 14 files changed, 2428 insertions(+), 58 deletions(-) create mode 100644 server/src/ledgrab/api/routes/preferences.py create mode 100644 server/src/ledgrab/static/css/dashboard-customize.css create mode 100644 server/src/ledgrab/static/js/features/dashboard-customize.ts create mode 100644 server/src/ledgrab/static/js/features/dashboard-layout.ts create mode 100644 server/tests/test_preferences_api.py diff --git a/TODO.md b/TODO.md index 6710b64..46a2a16 100644 --- a/TODO.md +++ b/TODO.md @@ -171,6 +171,52 @@ Phases are independent and CSS-only where possible — backend untouched. as a separate cleanup PR after the new design has soaked. - [WONTDO] Delete `mobile.css` — Phase 6 kept the filename. +## Dashboard Customization + +Per-account dashboard layout — slide-in Customize panel lets users +toggle section / perf-cell visibility, reorder via drag, change density, +pick presets, and import/export the layout as JSON. Server-synced via +`db.get_setting('dashboard_layout')` so settings follow the user. + +- [x] `js/features/dashboard-layout.ts` — schema (open registry of section + / perf-cell keys so v1.1 cards slot in with no migration), defaults, + 5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV), + localStorage cache + server sync, legacy-key migration from + `dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`. +- [x] `api/routes/preferences.py` — `GET/PUT/DELETE + /api/v1/preferences/dashboard-layout`. Treats payload as opaque + (frontend owns the schema); validates only that body is an object + with a numeric `version`. 6 pytest tests in + `tests/test_preferences_api.py` cover round-trip, default-empty, + validation, delete, and unknown-field passthrough. +- [x] `js/features/dashboard.ts` — sections rendered into a fragment map, + then assembled in layout-driven order; perf section stays pinned + top (chart-persistence reasons) but its visibility is layout- + driven. Layout-change subscription invalidates the in-place-update + optimization so density / order / visibility changes always + rebuild section HTML. +- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates + `getOrderedPerfCells()`; existing legacy `setPerfMode` writes + through to the layout so the global toggle and the customize + panel stay in sync. +- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css` + — slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓ + buttons for keyboard / TV remote, debounced (300 ms) autosave, + live preview while open. Reset / export / import actions. +- [x] i18n keys for `dashboard.customize.*` in en/ru/zh. +- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio + source. Schema key `audio-meters` already reserved. +- [ ] (v1.1) Alerts section — quiet by default, loud on issues. + Reserved key `alerts`. +- [ ] (v1.1) Live LED preview strip per running device. Reserved + key `led-preview`. +- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved + key `source-thumbs`. +- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes / + devices). Reserved key `pinned`. +- [ ] (v1.2) Patch/flow map — read-only mini graph of routing. + Reserved key `flow`. + ## BLE LED Controller Support (SP110E / Triones / Zengge / Govee) Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming. diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index ae716cc..6ef367c 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -31,6 +31,7 @@ from .routes.game_integration import router as game_integration_router from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_filters import router as audio_filters_router from .routes.pattern_templates import router as pattern_templates_router +from .routes.preferences import router as preferences_router router = APIRouter() router.include_router(system_router) @@ -62,5 +63,6 @@ router.include_router(game_integration_router) router.include_router(audio_processing_templates_router) router.include_router(audio_filters_router) router.include_router(pattern_templates_router) +router.include_router(preferences_router) __all__ = ["router"] diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py new file mode 100644 index 0000000..70a9b81 --- /dev/null +++ b/server/src/ledgrab/api/routes/preferences.py @@ -0,0 +1,75 @@ +"""User preferences routes — currently dashboard layout only. + +The dashboard layout schema is owned by the frontend (open registry of +section/cell keys); the backend treats the value as an opaque JSON blob, +validates it's a dict with a `version` field, and persists it under the +`dashboard_layout` settings key. +""" + +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import get_database +from ledgrab.storage.database import Database +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + +_DASHBOARD_LAYOUT_KEY = "dashboard_layout" + + +@router.get( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def get_dashboard_layout( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, Any]: + """Read the saved dashboard layout. Returns an empty object when no + layout has been saved yet — the frontend falls back to its built-in + default in that case.""" + value = db.get_setting(_DASHBOARD_LAYOUT_KEY) + return value if value is not None else {} + + +@router.put( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def put_dashboard_layout( + _: AuthRequired, + body: dict[str, Any] = Body(...), + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Save the dashboard layout. The body must be a JSON object with a + numeric `version` field; everything else is treated as opaque payload + that the frontend will validate on read.""" + if not isinstance(body, dict): + raise HTTPException(status_code=422, detail="Body must be a JSON object") + if not isinstance(body.get("version"), int): + raise HTTPException( + status_code=422, + detail="Layout must include a numeric 'version' field", + ) + db.set_setting(_DASHBOARD_LAYOUT_KEY, body) + return {"ok": True} + + +@router.delete( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def delete_dashboard_layout( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Delete the saved layout — frontend will revert to the default + on next load. Used by the 'Reset' button when the user wants + to clear the server-side override entirely.""" + db.set_setting(_DASHBOARD_LAYOUT_KEY, {}) + return {"ok": True} diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 9ebae89..91f0b9d 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -9,6 +9,7 @@ @import './calibration.css'; @import './advanced-calibration.css'; @import './dashboard.css'; +@import './dashboard-customize.css'; @import './streams.css'; @import './patterns.css'; @import './automations.css'; diff --git a/server/src/ledgrab/static/css/dashboard-customize.css b/server/src/ledgrab/static/css/dashboard-customize.css new file mode 100644 index 0000000..8105eba --- /dev/null +++ b/server/src/ledgrab/static/css/dashboard-customize.css @@ -0,0 +1,517 @@ +/* ── Dashboard Customize Panel ── + * Slide-in panel on the right edge. Doesn't cover the full viewport so + * users see live previews of changes as they toggle settings. + */ + +.dash-cust-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.18); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + z-index: calc(var(--z-modal, 1000) - 5); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +.dash-cust-backdrop.is-open { + opacity: 1; + pointer-events: auto; +} + +.dash-cust-panel { + position: fixed; + top: 60px; /* below transport bar */ + right: 0; + bottom: 0; + width: min(440px, 92vw); + background: var(--lux-bg-1, var(--card-bg)); + border-left: var(--lux-rule, 1px) solid var(--lux-line, var(--border-color)); + box-shadow: var(--lux-shadow-rack, -8px 0 32px rgba(0, 0, 0, 0.35)); + z-index: var(--z-modal, 1000); + transform: translateX(100%); + transition: transform 240ms cubic-bezier(0.2, 0.7, 0.3, 1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dash-cust-panel.is-open { + transform: translateX(0); +} + +@media (prefers-reduced-motion: reduce) { + .dash-cust-panel { transition: none; } + .dash-cust-backdrop { transition: none; } +} + +.dash-cust-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + flex: 0 0 auto; +} + +.dash-cust-header h2 { + margin: 0; + font-family: var(--font-display, var(--font-mono, monospace)); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--lux-ink, var(--text-color)); + position: relative; +} + +.dash-cust-header h2::after { + content: ''; + position: absolute; + left: 0; + bottom: -8px; + width: 32px; + height: 1px; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); +} + +.dash-cust-close { + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-close:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--ch-signal, var(--primary-color)); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent); +} + +.dash-cust-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 14px 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + /* Prevent scroll chaining: when the panel's scroll reaches its top + * or bottom, the wheel/touch scroll should NOT propagate to the + * underlying dashboard page. */ + overscroll-behavior: contain; +} + +/* Section blocks */ +.dash-cust-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dash-cust-section + .dash-cust-section { + margin-top: 4px; +} + +.dash-cust-h3 { + margin: 0 0 2px; + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--lux-ink-dim, var(--text-secondary)); + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 4px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-modified { + font-size: 0.55rem; + letter-spacing: 0.18em; + color: var(--ch-amber, var(--warning-color)); + margin-left: auto; + font-weight: 600; + padding: 1px 6px; + border: 1px solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent); + border-radius: 2px; +} + +/* Preset chips */ +.dash-cust-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dash-cust-chip { + background: var(--lux-bg-2, var(--bg-secondary)); + color: var(--lux-ink-dim, var(--text-secondary)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 6px 12px; + border-radius: 3px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-chip:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--lux-line-bold, var(--text-secondary)); +} + +.dash-cust-chip.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent); + color: var(--lux-ink, var(--text-color)); + border-color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent), + 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); +} + +/* Rows + lists */ +.dash-cust-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dash-cust-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--lux-bg-2, var(--bg-secondary)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + transition: background 150ms ease, border-color 150ms ease, transform 100ms ease; +} + +.dash-cust-row.is-dragging { + opacity: 0.55; + transform: scale(0.98); +} + +.dash-cust-row.is-drop-target { + border-color: var(--ch-signal, var(--primary-color)); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent); +} + +.dash-cust-row-fixed { + background: color-mix(in srgb, var(--lux-line, var(--border-color)) 30%, transparent); +} + +.dash-cust-row-drag { + cursor: grab; +} + +.dash-cust-row-drag:active { + cursor: grabbing; +} + +.dash-cust-row-label { + flex: 1 1 auto; + font-family: var(--font-body, inherit); + font-size: 0.78rem; + color: var(--lux-ink, var(--text-color)); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dash-cust-row-label .dash-cust-pin { + display: inline-flex; + align-items: center; + margin-right: 6px; + color: var(--lux-ink-mute, var(--text-muted)); +} + +.dash-cust-grip { + color: var(--lux-ink-mute, var(--text-muted)); + width: 14px; + height: 14px; + flex: 0 0 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.dash-cust-row-drag:hover .dash-cust-grip { + color: var(--lux-ink, var(--text-color)); +} + +/* Density buttons */ +.dash-cust-density-group { + display: inline-flex; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + overflow: hidden; +} + +.dash-cust-density { + background: transparent; + border: 0; + padding: 2px 6px; + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.6rem; + font-weight: 700; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.dash-cust-density:not(:last-child) { + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-density:hover { + color: var(--lux-ink, var(--text-color)); +} + +.dash-cust-density.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); + color: var(--lux-ink, var(--text-color)); +} + +/* Eye / toggle button */ +.dash-cust-eye, .dash-cust-arrow { + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + cursor: pointer; + flex: 0 0 26px; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-eye:hover, .dash-cust-arrow:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--lux-line-bold, var(--text-secondary)); +} + +.dash-cust-eye.is-on { + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, var(--lux-line, var(--border-color))); +} + +.dash-cust-arrow.is-active { + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, var(--lux-line, var(--border-color))); +} + +.dash-cust-arrow { + font-family: var(--font-mono, monospace); + font-size: 0.85rem; + font-weight: 700; +} + +/* Segmented controls (global options) */ +.dash-cust-row .dash-cust-label { + flex: 0 0 auto; + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--lux-ink-dim, var(--text-secondary)); + min-width: 80px; +} + +.dash-cust-seg { + display: inline-flex; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + overflow: hidden; + flex: 1 1 auto; +} + +.dash-cust-seg-btn { + flex: 1 1 auto; + background: transparent; + border: 0; + padding: 5px 8px; + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.dash-cust-seg-btn:not(:last-child) { + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-seg-btn:hover { + color: var(--lux-ink, var(--text-color)); +} + +.dash-cust-seg-btn.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); + color: var(--lux-ink, var(--text-color)); +} + +/* Mini selects (perf cell options). + * The project's components.css applies `select { width: 100%; padding: 9px 12px }` + * globally — we override both with higher specificity so the selects size to + * their content rather than blowing the row out past the panel edge. */ +.dash-cust-panel select.dash-cust-mini-select { + width: auto; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink, var(--text-color)); + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + padding: 3px 18px 3px 6px; + border-radius: 3px; + cursor: pointer; + flex: 1 1 auto; + min-width: 0; + height: 24px; + line-height: 1; +} + +.dash-cust-panel select.dash-cust-mini-select:focus { + outline: none; + border-color: var(--ch-signal, var(--primary-color)); +} + +/* Two-line perf-cell row. + * Top line carries the label + reorder + visibility controls so the cell + * name is *always* readable. Bottom line carries the per-cell options + * (mode / window / scale) labelled with tiny mono captions. */ +.dash-cust-cell-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px 10px; +} + +.dash-cust-cell-top { + display: flex; + align-items: center; + gap: 8px; +} + +.dash-cust-cell-opts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.dash-cust-cell-opt { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.dash-cust-cell-opt-k { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-muted)); + flex: 0 0 auto; +} + +/* Help / actions */ +.dash-cust-help { + margin: 0; + font-size: 0.65rem; + color: var(--lux-ink-mute, var(--text-muted)); + font-style: italic; +} + +.dash-cust-actions { + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding-top: 14px; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; +} + +.dash-cust-actions .btn { + font-size: 0.7rem; + padding: 6px 12px; +} + +/* Width-mode hooks: applied to dashboard-content, not the panel */ +#dashboard-content[data-layout-width="centered"] { + max-width: 1280px; + margin-left: auto; + margin-right: auto; +} + +#dashboard-content[data-layout-width="narrow"] { + max-width: 960px; + margin-left: auto; + margin-right: auto; +} + +#dashboard-content[data-layout-anim="off"] *, +#dashboard-content[data-layout-anim="off"] *::before, +#dashboard-content[data-layout-anim="off"] *::after { + animation-duration: 0ms !important; + transition-duration: 0ms !important; +} + +#dashboard-content[data-layout-anim="reduced"] *, +#dashboard-content[data-layout-anim="reduced"] *::before, +#dashboard-content[data-layout-anim="reduced"] *::after { + animation-duration: 60ms !important; + transition-duration: 80ms !important; +} + +/* Density variants per section */ +.dashboard-section[data-density="compact"] .dashboard-section-content { + gap: 10px; +} + +.dashboard-section[data-density="compact"] .dashboard-section-header { + margin-bottom: 10px; + padding-bottom: 6px; +} + +.dashboard-section[data-density="dense"] .dashboard-section-content { + gap: 6px; +} + +.dashboard-section[data-density="dense"] .dashboard-section-header { + margin-bottom: 6px; + padding-bottom: 4px; + font-size: 0.72rem; +} + +.dashboard-section[data-density="dense"] .dashboard-target { + padding: 8px 10px; +} + +/* Mobile collapse */ +@media (max-width: 720px) { + .dash-cust-panel { + top: 56px; + width: 100vw; + max-width: 100vw; + } +} diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 887fc01..90315c6 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -48,6 +48,12 @@ import { dashboardPauseClock, dashboardResumeClock, dashboardResetClock, toggleDashboardSection, changeDashboardPollInterval, } from './features/dashboard.ts'; +import { + hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer, +} from './features/dashboard-layout.ts'; +import { + openDashboardCustomize, closeDashboardCustomize, +} from './features/dashboard-customize.ts'; import { startEventsWS, stopEventsWS } from './core/events-ws.ts'; import { startEntityEventListeners } from './core/entity-events.ts'; import { @@ -294,6 +300,8 @@ Object.assign(window, { // dashboard loadDashboard, + openDashboardCustomize, + closeDashboardCustomize, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, @@ -692,6 +700,11 @@ document.addEventListener('DOMContentLoaded', async () => { // Load API key from localStorage before anything that triggers API calls setApiKey(localStorage.getItem('ledgrab_api_key')); + // Hydrate dashboard layout from localStorage cache so the first paint + // already reflects the user's saved customizations (no flash of + // default-then-custom). Server sync runs after auth. + hydrateDashboardLayoutFromCache(); + // Initialize locale (dispatches languageChanged which may trigger API calls) await initLocale(); @@ -786,6 +799,11 @@ document.addEventListener('DOMContentLoaded', async () => { loadDisplays(); loadTargetsTab(); + // Pull the server-side dashboard layout (per-account, follows user + // across browsers). Fire-and-forget — the cached layout is already + // active; this overwrites it if the server has a newer copy. + syncDashboardLayoutFromServer(); + // Trigger the active tab's loader — initTabs() ran before authRequired // was known, so its conditional loader call may have been skipped. const activeTab = localStorage.getItem('activeTab') || 'dashboard'; diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts new file mode 100644 index 0000000..e9aefe5 --- /dev/null +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -0,0 +1,576 @@ +/** + * Dashboard customization panel — slide-in panel that lets the user toggle + * section / perf-cell visibility, reorder them by drag, change density, + * pick presets, and import/export the layout as JSON. + * + * The panel writes through `dashboard-layout.ts` which debounces a server + * PUT and notifies subscribers — `dashboard.ts` listens and re-renders + * live, so every change shows immediately on the page behind the panel. + * + * Drag/drop is hand-rolled HTML5 drag-and-drop (no external dep). It only + * works on pointer devices; for keyboard / TV remote we expose ↑/↓ buttons + * on each row so the panel is fully reachable without a mouse. + */ +import { t } from '../core/i18n.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { + getDashboardLayout, + saveDashboardLayout, + applyDashboardPreset, + resetDashboardLayout, + exportDashboardLayoutJson, + importDashboardLayoutJson, + setSectionVisible, + setSectionOrder, + setSectionDensity, + setSectionCollapsedDefault, + setPerfCellVisible, + setPerfCellOrder, + setPerfCellMode, + setPerfCellWindow, + setPerfCellYScale, + setGlobalPerfMode, + setGlobalPerfWindow, + setGlobalConfig, + PRESETS, + subscribeDashboardLayout, + type DashboardLayoutV1, + type Density, + type PerfMode, + type SampleWindow, + type YScale, + type Width, + type AnimationsLevel, +} from './dashboard-layout.ts'; +import { + ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH, +} from '../core/icons.ts'; + +const ICON_DRAG = ''; +const ICON_LOCK = ''; + +const PANEL_ID = 'dashboard-customize-panel'; +const BACKDROP_ID = 'dashboard-customize-backdrop'; + +/** Sections that the user can reorder. The perf section is special-cased + * (always at top in v1; only its visibility / cells are configurable), + * so it's not part of this list. */ +const REORDERABLE_SECTIONS: readonly string[] = [ + 'integrations', + 'automations', + 'scenes', + 'sync-clocks', + 'targets', +] as const; + +const SECTION_LABEL_KEYS: Record = { + perf: 'dashboard.section.performance', + integrations: 'dashboard.section.integrations', + automations: 'dashboard.section.automations', + scenes: 'dashboard.section.scenes', + 'sync-clocks': 'dashboard.section.sync_clocks', + targets: 'dashboard.section.targets', +}; + +const PERF_CELL_LABEL_KEYS: Record = { + patches: 'dashboard.perf.active_patches', + fps: 'dashboard.perf.total_fps', + devices: 'dashboard.perf.devices', + cpu: 'dashboard.perf.cpu', + ram: 'dashboard.perf.ram', + gpu: 'dashboard.perf.gpu', + temp: 'dashboard.perf.temp', +}; + +let _unsubscribe: (() => void) | null = null; + +export function openDashboardCustomize(): void { + let panel = document.getElementById(PANEL_ID); + if (!panel) { + _mountPanel(); + panel = document.getElementById(PANEL_ID)!; + } + panel.classList.add('is-open'); + const backdrop = document.getElementById(BACKDROP_ID); + if (backdrop) backdrop.classList.add('is-open'); + _renderPanelBody(); + if (!_unsubscribe) { + _unsubscribe = subscribeDashboardLayout(() => _renderPanelBody()); + } +} + +export function closeDashboardCustomize(): void { + const panel = document.getElementById(PANEL_ID); + const backdrop = document.getElementById(BACKDROP_ID); + if (panel) panel.classList.remove('is-open'); + if (backdrop) backdrop.classList.remove('is-open'); + if (_unsubscribe) { _unsubscribe(); _unsubscribe = null; } +} + +function _mountPanel(): void { + const backdrop = document.createElement('div'); + backdrop.id = BACKDROP_ID; + backdrop.className = 'dash-cust-backdrop'; + backdrop.addEventListener('click', closeDashboardCustomize); + document.body.appendChild(backdrop); + + const panel = document.createElement('aside'); + panel.id = PANEL_ID; + panel.className = 'dash-cust-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'false'); + panel.setAttribute('aria-labelledby', 'dash-cust-title'); + panel.innerHTML = ` +
+

${t('dashboard.customize.title')}

+ +
+
+ `; + document.body.appendChild(panel); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && panel.classList.contains('is-open')) { + closeDashboardCustomize(); + } + }); +} + +function _renderPanelBody(): void { + const body = document.getElementById('dash-cust-body'); + if (!body) return; + const layout = getDashboardLayout(); + body.innerHTML = ` + ${_renderPresets(layout)} + ${_renderGlobal(layout)} + ${_renderSections(layout)} + ${_renderPerfCells(layout)} + ${_renderActions()} + `; + _bindHandlers(body); +} + +// ── Sub-renderers ──────────────────────────────────────────────────────── + +function _renderPresets(layout: DashboardLayoutV1): string { + const chips = Object.keys(PRESETS).map(name => { + const active = layout.presetActive === name; + return ``; + }).join(''); + const modifiedHint = layout.presetActive + ? '' + : `${t('dashboard.customize.modified')}`; + return `
+

${t('dashboard.customize.presets')}${modifiedHint}

+
${chips}
+
`; +} + +function _renderGlobal(layout: DashboardLayoutV1): string { + const widthOpts: { v: Width; k: string }[] = [ + { v: 'full', k: 'dashboard.customize.width.full' }, + { v: 'centered', k: 'dashboard.customize.width.centered' }, + { v: 'narrow', k: 'dashboard.customize.width.narrow' }, + ]; + const animOpts: { v: AnimationsLevel; k: string }[] = [ + { v: 'full', k: 'dashboard.customize.anim.full' }, + { v: 'reduced', k: 'dashboard.customize.anim.reduced' }, + { v: 'off', k: 'dashboard.customize.anim.off' }, + ]; + const modeOpts: { v: 'system' | 'app' | 'both'; k: string }[] = [ + { v: 'system', k: 'dashboard.perf.mode.system' }, + { v: 'app', k: 'dashboard.perf.mode.app' }, + { v: 'both', k: 'dashboard.perf.mode.both' }, + ]; + const windowOpts: SampleWindow[] = [30, 60, 120, 300]; + const widthBtns = widthOpts.map(o => ``).join(''); + const animBtns = animOpts.map(o => ``).join(''); + const modeBtns = modeOpts.map(o => ``).join(''); + const windowBtns = windowOpts.map(w => ``).join(''); + return `
+

${t('dashboard.customize.global')}

+
+ +
${widthBtns}
+
+
+ +
${animBtns}
+
+
+ +
${modeBtns}
+
+
+ +
${windowBtns}
+
+
`; +} + +function _renderSections(layout: DashboardLayoutV1): string { + const perfRow = (() => { + const perf = layout.sections.find(s => s.key === 'perf'); + if (!perf) return ''; + return `
+ + ${ICON_LOCK} + ${t(SECTION_LABEL_KEYS.perf)} + + ${_eyeBtn(perf.visible, 'section', 'perf')} +
`; + })(); + + const orderedSlugs = REORDERABLE_SECTIONS.filter(k => + layout.sections.some(s => s.key === k)); + const orderedFromLayout = layout.sections.map(s => s.key).filter(k => orderedSlugs.includes(k)); + + const rows = orderedFromLayout.map(key => { + const s = layout.sections.find(s => s.key === key); + if (!s) return ''; + const densityBtns: { v: Density; lbl: string }[] = [ + { v: 'comfortable', lbl: 'C' }, + { v: 'compact', lbl: 'M' }, + { v: 'dense', lbl: 'D' }, + ]; + const densityHtml = densityBtns.map(b => + `` + ).join(''); + return `
+ + ${t(SECTION_LABEL_KEYS[key] || key)} + ${densityHtml} + + + ${_collapseBtn(s.collapsedDefault, 'section', key)} + ${_eyeBtn(s.visible, 'section', key)} +
`; + }).join(''); + + return `
+

${t('dashboard.customize.sections')}

+ ${perfRow} +
${rows}
+

${t('dashboard.customize.drag_help')}

+
`; +} + +function _renderPerfCells(layout: DashboardLayoutV1): string { + const modeOpts: PerfMode[] = ['inherit', 'system', 'app', 'both']; + const windowOpts: (SampleWindow | 'inherit')[] = ['inherit', 30, 60, 120, 300]; + const yScaleOpts: YScale[] = ['auto', 'fixed', 'log']; + + const rows = layout.perfCells.map(c => { + const modeSel = ``; + const windowSel = ``; + const yScaleSel = ``; + return `
+
+ + ${t(PERF_CELL_LABEL_KEYS[c.key] || c.key)} + + + ${_eyeBtn(c.visible, 'cell', c.key)} +
+
+ + ${t('dashboard.customize.mode_short')} + ${modeSel} + + + ${t('dashboard.customize.window_short')} + ${windowSel} + + + ${t('dashboard.customize.scale_short')} + ${yScaleSel} + +
+
`; + }).join(''); + + return `
+

${t('dashboard.customize.perf_cells')}

+
${rows}
+

${t('dashboard.customize.cell_drag_help')}

+
`; +} + +function _renderActions(): string { + return `
+ + + +
`; +} + +function _eyeBtn(visible: boolean, kind: 'section' | 'cell', key: string): string { + const dataAttr = kind === 'section' ? 'data-section-toggle' : 'data-cell-toggle'; + const label = visible ? t('dashboard.customize.hide') : t('dashboard.customize.show'); + return ``; +} + +function _collapseBtn(collapsed: boolean, kind: 'section', key: string): string { + const label = collapsed ? t('dashboard.customize.collapse_default.on') : t('dashboard.customize.collapse_default.off'); + return ``; +} + +// ── Handlers ───────────────────────────────────────────────────────────── + +function _bindHandlers(root: HTMLElement): void { + // Presets + root.querySelectorAll('[data-preset]').forEach(btn => { + btn.addEventListener('click', () => { + const name = btn.dataset.preset!; + applyDashboardPreset(name); + }); + }); + + // Global toggles + root.querySelectorAll('[data-global-width]').forEach(btn => { + btn.addEventListener('click', () => { + saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { width: btn.dataset.globalWidth as Width })); + }); + }); + root.querySelectorAll('[data-global-anim]').forEach(btn => { + btn.addEventListener('click', () => { + saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { animations: btn.dataset.globalAnim as AnimationsLevel })); + }); + }); + root.querySelectorAll('[data-global-perfmode]').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.globalPerfmode as 'system' | 'app' | 'both'; + saveDashboardLayout(setGlobalPerfMode(getDashboardLayout(), mode)); + }); + }); + + // Section visibility / density / order / collapse-default + root.querySelectorAll('[data-section-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionToggle!; + const layout = getDashboardLayout(); + const cur = layout.sections.find(s => s.key === key); + if (!cur) return; + saveDashboardLayout(setSectionVisible(layout, key, !cur.visible)); + }); + }); + root.querySelectorAll('[data-section-density]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionDensity!; + const density = btn.dataset.density as Density; + saveDashboardLayout(setSectionDensity(getDashboardLayout(), key, density)); + }); + }); + root.querySelectorAll('[data-section-collapse-default]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionCollapseDefault!; + const layout = getDashboardLayout(); + const cur = layout.sections.find(s => s.key === key); + if (!cur) return; + saveDashboardLayout(setSectionCollapsedDefault(layout, key, !cur.collapsedDefault)); + }); + }); + root.querySelectorAll('[data-move]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionKey!; + const dir = btn.dataset.move as 'up' | 'down'; + _moveSection(key, dir); + }); + }); + + // Perf cells + root.querySelectorAll('[data-cell-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.cellToggle!; + const layout = getDashboardLayout(); + const cur = layout.perfCells.find(c => c.key === key); + if (!cur) return; + saveDashboardLayout(setPerfCellVisible(layout, key, !cur.visible)); + }); + }); + root.querySelectorAll('[data-cell-mode]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellMode!; + saveDashboardLayout(setPerfCellMode(getDashboardLayout(), key, sel.value as PerfMode)); + }); + }); + root.querySelectorAll('[data-cell-window]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellWindow!; + const raw = sel.value; + const win: SampleWindow | 'inherit' = raw === 'inherit' + ? 'inherit' + : (parseInt(raw, 10) as SampleWindow); + saveDashboardLayout(setPerfCellWindow(getDashboardLayout(), key, win)); + }); + }); + root.querySelectorAll('[data-global-perfwindow]').forEach(btn => { + btn.addEventListener('click', () => { + const w = parseInt(btn.dataset.globalPerfwindow || '120', 10) as SampleWindow; + saveDashboardLayout(setGlobalPerfWindow(getDashboardLayout(), w)); + }); + }); + root.querySelectorAll('[data-cell-yscale]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellYscale!; + saveDashboardLayout(setPerfCellYScale(getDashboardLayout(), key, sel.value as YScale)); + }); + }); + root.querySelectorAll('[data-cell-move]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.cellKey!; + const dir = btn.dataset.cellMove as 'up' | 'down'; + _movePerfCell(key, dir); + }); + }); + + // Drag-and-drop reorder + _bindDragSort(root, '#dash-cust-section-list', 'data-section-key', (orderedKeys) => { + const layout = getDashboardLayout(); + // Preserve relative position of fixed/non-reorderable keys (perf). + const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k)); + const merged = [...nonReorderable, ...orderedKeys]; + saveDashboardLayout(setSectionOrder(layout, merged)); + }); + _bindDragSort(root, '#dash-cust-cell-list', 'data-cell-key', (orderedKeys) => { + saveDashboardLayout(setPerfCellOrder(getDashboardLayout(), orderedKeys)); + }); + + // Actions + const exportBtn = root.querySelector('[data-action="export"]'); + if (exportBtn) exportBtn.addEventListener('click', _doExport); + const importBtn = root.querySelector('[data-action="import"]'); + if (importBtn) importBtn.addEventListener('click', _doImport); + const resetBtn = root.querySelector('[data-action="reset"]'); + if (resetBtn) resetBtn.addEventListener('click', async () => { + const confirmed = await showConfirm( + t('dashboard.customize.reset_confirm'), + t('dashboard.customize.reset'), + ); + if (confirmed) resetDashboardLayout(); + }); +} + +function _moveSection(key: string, dir: 'up' | 'down'): void { + const layout = getDashboardLayout(); + const orderable = layout.sections + .map(s => s.key) + .filter(k => REORDERABLE_SECTIONS.includes(k)); + const idx = orderable.indexOf(key); + if (idx < 0) return; + const swap = dir === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= orderable.length) return; + const next = [...orderable]; + [next[idx], next[swap]] = [next[swap], next[idx]]; + const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k)); + saveDashboardLayout(setSectionOrder(layout, [...nonReorderable, ...next])); +} + +function _movePerfCell(key: string, dir: 'up' | 'down'): void { + const layout = getDashboardLayout(); + const order = layout.perfCells.map(c => c.key); + const idx = order.indexOf(key); + if (idx < 0) return; + const swap = dir === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= order.length) return; + const next = [...order]; + [next[idx], next[swap]] = [next[swap], next[idx]]; + saveDashboardLayout(setPerfCellOrder(layout, next)); +} + +// ── Hand-rolled drag-and-drop sort ────────────────────────────────────── + +function _bindDragSort( + root: HTMLElement, + listSelector: string, + keyAttr: string, + onReorder: (orderedKeys: string[]) => void, +): void { + const list = root.querySelector(listSelector); + if (!list) return; + let dragKey: string | null = null; + + list.querySelectorAll('.dash-cust-row-drag').forEach(row => { + row.addEventListener('dragstart', (e) => { + dragKey = row.getAttribute(keyAttr); + row.classList.add('is-dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Required by Firefox to enable drag. + e.dataTransfer.setData('text/plain', dragKey || ''); + } + }); + row.addEventListener('dragend', () => { + row.classList.remove('is-dragging'); + dragKey = null; + list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); + }); + row.addEventListener('dragover', (e) => { + if (!dragKey) return; + e.preventDefault(); + list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); + row.classList.add('is-drop-target'); + }); + row.addEventListener('drop', (e) => { + e.preventDefault(); + const targetKey = row.getAttribute(keyAttr); + if (!dragKey || !targetKey || dragKey === targetKey) return; + const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag')); + const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || ''); + const fromIdx = orderedKeys.indexOf(dragKey); + const toIdx = orderedKeys.indexOf(targetKey); + if (fromIdx < 0 || toIdx < 0) return; + const [moved] = orderedKeys.splice(fromIdx, 1); + orderedKeys.splice(toIdx, 0, moved); + onReorder(orderedKeys.filter(Boolean)); + }); + }); +} + +// ── Export / import ───────────────────────────────────────────────────── + +function _doExport(): void { + const json = exportDashboardLayoutJson(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ledgrab-dashboard-layout-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(t('dashboard.customize.exported'), 'success'); +} + +function _doImport(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + if (importDashboardLayoutJson(text)) { + showToast(t('dashboard.customize.imported'), 'success'); + } else { + showToast(t('dashboard.customize.import_failed'), 'error'); + } + } catch { + showToast(t('dashboard.customize.import_failed'), 'error'); + } + }; + input.click(); +} diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts new file mode 100644 index 0000000..f12b9ac --- /dev/null +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -0,0 +1,579 @@ +/** + * Dashboard layout — schema, defaults, presets, and persistence for the + * customizable dashboard. + * + * Storage strategy: + * - localStorage `dashboard_layout_v1` is the cache (instant first-paint). + * - Server `GET/PUT /preferences/dashboard-layout` is the source of truth + * across browsers; pulled after auth, replaces local on mismatch. + * - Save path: PUT to server -> localStorage cache -> notify subscribers. + * + * Schema is intentionally an open registry: section/cell `key`s are strings, + * not a closed enum. New cards can be added in v1.1+ (audio meters, alerts, + * preview strips, etc.) without a schema bump or migration. + */ +import { fetchWithAuth } from '../core/api.ts'; + +const LS_KEY = 'dashboard_layout_v1'; +const SCHEMA_VERSION = 1; + +export type SectionKey = + | 'perf' + | 'integrations' + | 'automations' + | 'scenes' + | 'sync-clocks' + | 'targets' + // Reserved registry keys for v1.1+ (so saved layouts forward-compat). + | 'audio-meters' + | 'alerts' + | 'led-preview' + | 'source-thumbs' + | 'pinned' + | 'flow'; + +export type PerfCellKey = + | 'patches' + | 'fps' + | 'devices' + | 'cpu' + | 'ram' + | 'gpu' + | 'temp' + // Reserved. + | 'network' + | 'disk' + | 'audio-peak'; + +export type Density = 'comfortable' | 'compact' | 'dense'; +export type PerfMode = 'system' | 'app' | 'both' | 'inherit'; +export type YScale = 'auto' | 'fixed' | 'log'; +export type SampleWindow = 30 | 60 | 120 | 300; +export type Width = 'full' | 'centered' | 'narrow'; +export type AccentSource = 'target' | 'palette' | 'mono'; +export type AnimationsLevel = 'full' | 'reduced' | 'off'; +export type EmptyStateMode = 'hide' | 'cta' | 'skeleton'; +export type ToolbarPos = 'top' | 'bottom' | 'floating'; + +export interface SectionConfig { + key: string; + visible: boolean; + collapsedDefault: boolean; + density: Density; + /** Per-section options (sort, filters, etc.). Versioned per-section + * via `_v` so we can migrate one section without touching others. */ + options: Record; +} + +export interface PerfCellConfig { + key: string; + visible: boolean; + /** `inherit` defers to the global perf mode (system/app/both); a + * per-cell value pins that cell to one mode regardless of global. */ + mode: PerfMode; + span: 1 | 2; + /** `'inherit'` defers to `global.window`; a numeric value pins the + * cell's spark to that sample window regardless of global. */ + window: SampleWindow | 'inherit'; + yScale: YScale; + precision: 0 | 1 | 2; + showSubtitle: boolean; + showRefLine: boolean; + colorOverride?: string; +} + +export interface GlobalConfig { + width: Width; + accent: AccentSource; + animations: AnimationsLevel; + emptyState: EmptyStateMode; + toolbarPosition: ToolbarPos; + autoCollapseRunningEmpty: boolean; + showTutorial: boolean; + /** Global perf mode default — used when a cell has `mode: 'inherit'`. */ + perfMode: 'system' | 'app' | 'both'; + /** Global spark sample-window default in seconds — used when a cell + * has `window: 'inherit'`. */ + perfWindow: SampleWindow; + /** Poll interval for the perf strip + dashboard refresh, milliseconds. */ + pollMs: number; +} + +export interface DashboardLayoutV1 { + version: 1; + sections: SectionConfig[]; + perfCells: PerfCellConfig[]; + global: GlobalConfig; + /** Active preset key when the layout matches a built-in unmodified. + * Cleared on any user edit so the panel can show "modified" state. */ + presetActive?: string; +} + +const _defaultSection = (key: string, visible = true): SectionConfig => ({ + key, + visible, + collapsedDefault: false, + density: 'comfortable', + options: {}, +}); + +const _defaultPerfCell = (key: string, visible = true): PerfCellConfig => ({ + key, + visible, + mode: 'inherit', + span: 1, + window: 'inherit', + yScale: 'auto', + precision: 1, + showSubtitle: true, + showRefLine: true, +}); + +export const DEFAULT_LAYOUT: DashboardLayoutV1 = { + version: SCHEMA_VERSION, + sections: [ + _defaultSection('perf'), + _defaultSection('integrations'), + _defaultSection('automations'), + _defaultSection('scenes'), + _defaultSection('sync-clocks'), + _defaultSection('targets'), + ], + perfCells: [ + _defaultPerfCell('patches'), + _defaultPerfCell('fps'), + _defaultPerfCell('devices'), + _defaultPerfCell('cpu'), + _defaultPerfCell('ram'), + _defaultPerfCell('gpu'), + _defaultPerfCell('temp', false), + ], + global: { + width: 'full', + accent: 'target', + animations: 'full', + emptyState: 'hide', + toolbarPosition: 'top', + autoCollapseRunningEmpty: false, + showTutorial: true, + perfMode: 'both', + perfWindow: 120, + pollMs: 1000, + }, + presetActive: 'studio', +}; + +/** Built-in presets — each is a complete layout the user can apply with one + * click. Stored as functions so they always produce a fresh object (no + * shared mutable references). */ +export const PRESETS: Record DashboardLayoutV1> = { + studio: () => _clone(DEFAULT_LAYOUT, 'studio'), + + operator: () => { + const l = _clone(DEFAULT_LAYOUT, 'operator'); + const hide = new Set(['integrations', 'scenes', 'sync-clocks']); + l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); + l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); + return l; + }, + + showrunner: () => { + const l = _clone(DEFAULT_LAYOUT, 'showrunner'); + const hide = new Set(['perf', 'integrations']); + l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); + l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); + return l; + }, + + diagnostics: () => { + const l = _clone(DEFAULT_LAYOUT, 'diagnostics'); + l.perfCells = l.perfCells.map(c => ({ + ...c, + visible: true, + window: 'inherit', + showSubtitle: true, + showRefLine: true, + })); + l.global = { ...l.global, perfMode: 'both', perfWindow: 300, pollMs: 500 }; + return l; + }, + + tv: () => { + const l = _clone(DEFAULT_LAYOUT, 'tv'); + l.sections = l.sections.map(s => ({ ...s, density: 'dense' })); + const keep = new Set(['perf', 'targets']); + l.sections = l.sections.map(s => keep.has(s.key) ? s : { ...s, visible: false }); + l.global = { ...l.global, width: 'centered', toolbarPosition: 'top' }; + return l; + }, +}; + +function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayoutV1 { + return { + version: layout.version, + sections: layout.sections.map(s => ({ ...s, options: { ...s.options } })), + perfCells: layout.perfCells.map(c => ({ ...c })), + global: { ...layout.global }, + presetActive, + }; +} + +let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio'); +let _serverSyncedOnce = false; +const _listeners = new Set<() => void>(); +let _saveTimer: ReturnType | null = null; + +/** Read the current layout. Always returns a defensive copy so callers + * can't mutate it directly — mutations must go through `saveDashboardLayout`. */ +export function getDashboardLayout(): DashboardLayoutV1 { + return _clone(_current, _current.presetActive); +} + +/** Subscribe to layout changes. Returns an unsubscribe function. */ +export function subscribeDashboardLayout(fn: () => void): () => void { + _listeners.add(fn); + return () => _listeners.delete(fn); +} + +function _notify(): void { + for (const fn of _listeners) { + try { fn(); } catch (e) { console.error('dashboard layout listener', e); } + } +} + +/** Hydrate from localStorage cache (synchronous, for first-paint). Falls + * back to defaults + legacy-key migration if no cached layout exists. */ +export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + const merged = _mergeWithDefaults(parsed); + _current = merged; + return merged; + } + } catch (e) { + console.warn('dashboard layout cache parse failed', e); + } + // No cache — pull from legacy keys so first migration is seamless. + _current = _migrateFromLegacyKeys(); + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + return _clone(_current, _current.presetActive); +} + +/** Pull layout from server after auth. Replaces local cache if server has + * a saved layout, otherwise pushes the local cache up. Safe to call + * before login (will no-op on auth error). */ +export async function syncDashboardLayoutFromServer(): Promise { + if (_serverSyncedOnce) return; + try { + const resp = await fetchWithAuth('/preferences/dashboard-layout'); + if (!resp || !resp.ok) return; + const data = await resp.json(); + if (data && typeof data === 'object' && data.version) { + const merged = _mergeWithDefaults(data); + _current = merged; + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + _notify(); + } else { + // Server has nothing — push our cached/default layout up. + await _pushToServer(_current); + } + _serverSyncedOnce = true; + } catch (e) { + // Network or auth failure — keep using cache. + console.warn('dashboard layout server sync failed', e); + } +} + +/** Persist a layout. Updates in-memory state immediately, debounces + * the network write, and notifies listeners synchronously. */ +export function saveDashboardLayout(next: DashboardLayoutV1): void { + _current = _clone(next, next.presetActive); + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + _notify(); + if (_saveTimer) clearTimeout(_saveTimer); + _saveTimer = setTimeout(() => { + _saveTimer = null; + _pushToServer(_current).catch(e => console.warn('dashboard layout server PUT failed', e)); + }, 300); +} + +async function _pushToServer(layout: DashboardLayoutV1): Promise { + try { + await fetchWithAuth('/preferences/dashboard-layout', { + method: 'PUT', + body: JSON.stringify(layout), + }); + } catch (e) { + console.warn('dashboard layout PUT failed', e); + } +} + +/** Apply a built-in preset and persist it. */ +export function applyDashboardPreset(name: string): void { + const factory = PRESETS[name]; + if (!factory) return; + saveDashboardLayout(factory()); +} + +/** Reset to the studio default. */ +export function resetDashboardLayout(): void { + saveDashboardLayout(PRESETS.studio()); +} + +/** Export the current layout as a downloadable JSON string. */ +export function exportDashboardLayoutJson(): string { + return JSON.stringify(_current, null, 2); +} + +/** Import a JSON layout string. Returns true on success. */ +export function importDashboardLayoutJson(json: string): boolean { + try { + const parsed = JSON.parse(json); + if (!parsed || typeof parsed !== 'object') return false; + const merged = _mergeWithDefaults(parsed); + merged.presetActive = undefined; + saveDashboardLayout(merged); + return true; + } catch (e) { + console.warn('dashboard layout import failed', e); + return false; + } +} + +// ── Helpers exposed to other modules ───────────────────────────────────── + +export function getOrderedSections(): SectionConfig[] { + return _current.sections.map(s => ({ ...s, options: { ...s.options } })); +} + +export function getOrderedPerfCells(): PerfCellConfig[] { + return _current.perfCells.map(c => ({ ...c })); +} + +export function getSection(key: string): SectionConfig | undefined { + const s = _current.sections.find(s => s.key === key); + return s ? { ...s, options: { ...s.options } } : undefined; +} + +export function getPerfCell(key: string): PerfCellConfig | undefined { + const c = _current.perfCells.find(c => c.key === key); + return c ? { ...c } : undefined; +} + +export function isSectionVisible(key: string): boolean { + return _current.sections.find(s => s.key === key)?.visible ?? true; +} + +export function isPerfCellVisible(key: string): boolean { + return _current.perfCells.find(c => c.key === key)?.visible ?? true; +} + +export function getGlobalConfig(): GlobalConfig { + return { ..._current.global }; +} + +/** Effective perf mode for a given cell — resolves `inherit`. */ +export function effectivePerfMode(cellKey: string): 'system' | 'app' | 'both' { + const cell = _current.perfCells.find(c => c.key === cellKey); + if (!cell || cell.mode === 'inherit') return _current.global.perfMode; + return cell.mode; +} + +/** Effective spark window for a given cell — resolves `inherit`. */ +export function effectivePerfWindow(cellKey: string): SampleWindow { + const cell = _current.perfCells.find(c => c.key === cellKey); + if (!cell || cell.window === 'inherit') return _current.global.perfWindow; + return cell.window; +} + +// ── Mutation helpers — return a new layout, don't persist ──────────────── + +export function setSectionVisible(layout: DashboardLayoutV1, key: string, visible: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.visible = visible; + next.presetActive = undefined; + return next; +} + +export function setSectionOrder(layout: DashboardLayoutV1, orderedKeys: string[]): DashboardLayoutV1 { + const next = _clone(layout); + const map = new Map(next.sections.map(s => [s.key, s])); + const reordered: SectionConfig[] = []; + for (const k of orderedKeys) { + const s = map.get(k); + if (s) { reordered.push(s); map.delete(k); } + } + // Append any sections not in the order list (e.g. new registry entries). + for (const s of map.values()) reordered.push(s); + next.sections = reordered; + next.presetActive = undefined; + return next; +} + +export function setSectionDensity(layout: DashboardLayoutV1, key: string, density: Density): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.density = density; + next.presetActive = undefined; + return next; +} + +export function setSectionCollapsedDefault(layout: DashboardLayoutV1, key: string, collapsed: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.collapsedDefault = collapsed; + next.presetActive = undefined; + return next; +} + +export function setPerfCellVisible(layout: DashboardLayoutV1, key: string, visible: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.visible = visible; + next.presetActive = undefined; + return next; +} + +export function setPerfCellOrder(layout: DashboardLayoutV1, orderedKeys: string[]): DashboardLayoutV1 { + const next = _clone(layout); + const map = new Map(next.perfCells.map(c => [c.key, c])); + const reordered: PerfCellConfig[] = []; + for (const k of orderedKeys) { + const c = map.get(k); + if (c) { reordered.push(c); map.delete(k); } + } + for (const c of map.values()) reordered.push(c); + next.perfCells = reordered; + next.presetActive = undefined; + return next; +} + +export function setPerfCellMode(layout: DashboardLayoutV1, key: string, mode: PerfMode): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.mode = mode; + next.presetActive = undefined; + return next; +} + +export function setPerfCellWindow(layout: DashboardLayoutV1, key: string, window: SampleWindow | 'inherit'): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.window = window; + next.presetActive = undefined; + return next; +} + +export function setGlobalPerfWindow(layout: DashboardLayoutV1, window: SampleWindow): DashboardLayoutV1 { + const next = _clone(layout); + next.global.perfWindow = window; + next.presetActive = undefined; + return next; +} + +export function setPerfCellYScale(layout: DashboardLayoutV1, key: string, yScale: YScale): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.yScale = yScale; + next.presetActive = undefined; + return next; +} + +export function setGlobalPerfMode(layout: DashboardLayoutV1, mode: 'system' | 'app' | 'both'): DashboardLayoutV1 { + const next = _clone(layout); + next.global.perfMode = mode; + next.presetActive = undefined; + return next; +} + +export function setGlobalConfig(layout: DashboardLayoutV1, patch: Partial): DashboardLayoutV1 { + const next = _clone(layout); + next.global = { ...next.global, ...patch }; + next.presetActive = undefined; + return next; +} + +// ── Internal: merge / migrate ──────────────────────────────────────────── + +/** Merge a (possibly partial or older) layout with current defaults. New + * registry keys not in the saved layout are appended to the end with + * default settings; unknown keys in the saved layout are dropped. */ +function _mergeWithDefaults(input: unknown): DashboardLayoutV1 { + const base = _clone(DEFAULT_LAYOUT); + if (!input || typeof input !== 'object') return base; + const obj = input as Partial; + + if (Array.isArray(obj.sections)) { + const known = new Map(base.sections.map(s => [s.key, s])); + const reordered: SectionConfig[] = []; + for (const s of obj.sections as SectionConfig[]) { + const def = known.get(s.key); + if (!def) continue; + reordered.push({ + ...def, + ...s, + options: { ...def.options, ...(s.options || {}) }, + }); + known.delete(s.key); + } + for (const s of known.values()) reordered.push(s); + base.sections = reordered; + } + + if (Array.isArray(obj.perfCells)) { + const known = new Map(base.perfCells.map(c => [c.key, c])); + const reordered: PerfCellConfig[] = []; + for (const c of obj.perfCells as PerfCellConfig[]) { + const def = known.get(c.key); + if (!def) continue; + reordered.push({ ...def, ...c }); + known.delete(c.key); + } + for (const c of known.values()) reordered.push(c); + base.perfCells = reordered; + } + + if (obj.global && typeof obj.global === 'object') { + base.global = { ...base.global, ...obj.global }; + } + + if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive; + return base; +} + +/** First-time migration from legacy keys (`dashboard_collapsed`, + * `perfMetricsMode`, `perfChartColor_*`). Reads them, builds a layout, + * then leaves the legacy keys in place — they remain harmless and + * some still drive existing UI paths until fully cut over. */ +function _migrateFromLegacyKeys(): DashboardLayoutV1 { + const layout = _clone(DEFAULT_LAYOUT, 'studio'); + + try { + const collapsedRaw = localStorage.getItem('dashboard_collapsed'); + if (collapsedRaw) { + const collapsed = JSON.parse(collapsedRaw) as Record; + for (const s of layout.sections) { + if (collapsed[s.key]) s.collapsedDefault = true; + } + } + } catch { /* ignore */ } + + try { + const mode = localStorage.getItem('perfMetricsMode'); + if (mode === 'system' || mode === 'app' || mode === 'both') { + layout.global.perfMode = mode; + } + } catch { /* ignore */ } + + for (const cell of layout.perfCells) { + try { + const color = localStorage.getItem(`perfChartColor_${cell.key}`); + if (color) cell.colorOverride = color; + } catch { /* ignore */ } + } + + return layout; +} diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 0bc0469..595811a 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -6,17 +6,26 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; -import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices } from './perf-charts.ts'; +import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices, rerenderPerfGrid } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; import { ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, - ICON_PLUG, ICON_HOME, ICON_RADIO, + ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS, } from '../core/icons.ts'; import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts'; import { cardColorStyle } from '../core/card-colors.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; +import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts'; + +function _applyGlobalLayoutAttrs(): void { + const c = document.getElementById('dashboard-content'); + if (!c) return; + const g = getGlobalConfig(); + c.dataset.layoutWidth = g.width; + c.dataset.layoutAnim = g.animations; +} import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; @@ -90,7 +99,11 @@ function _startUptimeTimer(): void { if (!el) continue; const seconds = _getInterpolatedUptime(id); if (seconds != null) { - el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`; + // Pure text — the .mod-metric "UPTIME" label already + // carries the icon meaning, and dropping it gives the + // value enough room for "4m 32s" / "1h 17m" without + // clipping inside the fixed-width metric cell. + el.textContent = formatUptime(seconds); } } }, 1000); @@ -212,7 +225,24 @@ function _updateRunningMetrics(enrichedRunning: any[]): void { } const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`); - if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); } + if (errorsEl) { + // Plain numeric in the big value — cleaner at display-font + // size. The status glyph (✓ / ⚠) sits next to the small + // label at the top of the cell; swap it here too so it + // reflects the live error count without flicker. + errorsEl.textContent = formatCompact(errors); + errorsEl.classList.toggle('has-errors', errors > 0); + (errorsEl as HTMLElement).title = String(errors); + const cell = document.querySelector(`[data-errors-cell="${CSS.escape(target.id)}"]`); + if (cell) { + const labelEl = cell.querySelector('.k'); + if (labelEl) { + const labelText = labelEl.querySelector('[data-i18n]')?.textContent || t('dashboard.errors'); + labelEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${labelText}`; + } + cell.classList.toggle('has-errors', errors > 0); + } + } // Update health dot — prefer streaming reachability when processing const isLed = target.target_type === 'led' || target.target_type === 'wled'; @@ -455,8 +485,22 @@ export function changeDashboardPollInterval(value: string | number): void { } function _getCollapsedSections(): Record { - try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; } - catch { return {}; } + let userOverrides: Record = {}; + try { userOverrides = JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; } + catch { /* ignore */ } + // Layered: layout's `collapsedDefault` is the floor; the user's + // per-session toggle overrides it. Lets users start every section + // collapsed via Customize without losing in-session expand/collapse. + const merged: Record = {}; + for (const s of getOrderedSections()) { + merged[s.key] = userOverrides[s.key] ?? s.collapsedDefault; + } + // Subsections like 'running' / 'stopped' aren't in the layout — preserve + // user overrides as-is. + for (const k of Object.keys(userOverrides)) { + if (!(k in merged)) merged[k] = userOverrides[k]; + } + return merged; } export function toggleDashboardSection(sectionKey: string): void { @@ -503,11 +547,17 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; + // Only render the count pill when there's an actual count to show. + // The Performance header passes '' (no item count makes sense here) + // and was rendering an empty grey badge next to the title. + const countHtml = (count !== '' && count != null) + ? `${count}` + : ''; return `
${label} - ${count} + ${countHtml} ${extraHtml}
`; @@ -649,6 +699,14 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise = {}; + // Integrations section (HA + MQTT sources) const totalIntSources = haStatus.total_sources + mqttStatus.total_sources; const totalIntConnected = haStatus.connected_count + mqttStatus.connected_count; @@ -656,7 +714,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise _renderIntegrationCard(c)).join(''); const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join(''); const intGrid = `
${haCards}${mqttCards}
`; - dynamicHtml += `
+ sectionFragments['integrations'] = `
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)} ${_sectionContent('integrations', intGrid)}
`; @@ -670,7 +728,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise renderDashboardAutomation(a, sceneMap)).join(''); const automationGrid = `
${automationItems}
`; - dynamicHtml += `
+ sectionFragments['automations'] = `
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)} ${_sectionContent('automations', automationGrid)}
`; @@ -680,7 +738,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { const sceneSec = renderScenePresetsSection(scenePresets); if (sceneSec && typeof sceneSec === 'object') { - dynamicHtml += `
+ sectionFragments['scenes'] = `
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)} ${_sectionContent('scenes', sceneSec.content)}
`; @@ -691,7 +749,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join(''); const clockGrid = `
${clockCards}
`; - dynamicHtml += `
+ sectionFragments['sync-clocks'] = `
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)} ${_sectionContent('sync-clocks', clockGrid)}
`; @@ -720,33 +778,62 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise`; } - dynamicHtml += `
+ sectionFragments['targets'] = `
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)} ${_sectionContent('targets', targetsInner)}
`; } + + // Now assemble in layout-driven order, skipping invisible + // sections and the perf section (which is always rendered + // separately at the top for chart-persistence reasons). + for (const section of getOrderedSections()) { + if (section.key === 'perf') continue; + if (!section.visible) continue; + const html = sectionFragments[section.key]; + if (html) dynamicHtml += html; + } } // First load: build everything in one innerHTML to avoid flicker. // Poll-interval control was moved to the transport bar (it's global, - // not dashboard-specific) — toolbar now only keeps the tutorial - // help button. + // not dashboard-specific) — toolbar now keeps the tutorial help + // button + the new "Customize" gear that opens the layout panel. const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); - const toolbar = `
`; + const perfVisible = isSectionVisible('perf'); + const customizeBtn = ``; + const tutorialBtn = ``; + const toolbar = `
${customizeBtn}${tutorialBtn}
`; if (isFirstLoad) { - container.innerHTML = `${toolbar}
- ${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())} - ${_sectionContent('perf', renderPerfSection())} -
-
${dynamicHtml}
`; - await initPerfCharts(); + const perfBlock = perfVisible + ? `
+ ${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())} + ${_sectionContent('perf', renderPerfSection())} +
` + : ''; + container.innerHTML = `${toolbar}${perfBlock}
${dynamicHtml}
`; + _applyGlobalLayoutAttrs(); + if (perfVisible) await initPerfCharts(); // Event delegation for scene preset cards (attached once, works across innerHTML refreshes) initScenePresetDelegation(container); } else { + // Toggle perf visibility on subsequent renders without + // destroying its DOM (charts persist). + const existingPerf = container.querySelector('.dashboard-perf-persistent') as HTMLElement | null; + if (existingPerf) { + existingPerf.style.display = perfVisible ? '' : 'none'; + } const dynamic = container.querySelector('.dashboard-dynamic'); if (dynamic && dynamic.innerHTML !== dynamicHtml) { dynamic.innerHTML = dynamicHtml; } + _applyGlobalLayoutAttrs(); + } + // Apply per-section density tags so CSS selectors like + // `.dashboard-section[data-density="dense"]` can take effect. + for (const s of getOrderedSections()) { + const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null; + if (el) el.dataset.density = s.density; } _lastRunningIds = runningIds; _lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); @@ -853,12 +940,12 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
- Uptime + ${ICON_CLOCK} Uptime ${uptime}
-
- Errors - ${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)} +
+ ${errors > 0 ? ICON_WARNING : ICON_OK} Errors + ${formatCompact(errors)}
@@ -1089,6 +1176,46 @@ document.addEventListener('languageChanged', () => { loadDashboard(); }); +// Live-preview: re-render the dashboard whenever the customize panel +// changes the saved layout. Uses a debounce so dragging or rapid +// toggling doesn't thrash the DOM. The perf strip is preserved across +// re-renders (DOM persistence), so toggling its visibility is the +// only re-init path that needs `forceFullRender`. +let _layoutChangeRenderTimer: ReturnType | undefined; +subscribeDashboardLayout(() => { + if (!apiKey) return; + if (!_isDashboardActive()) return; + clearTimeout(_layoutChangeRenderTimer); + _layoutChangeRenderTimer = setTimeout(() => { + // Invalidate the in-place-update optimization in `loadDashboard` + // — section HTML must be rebuilt when sections reorder, change + // density, or toggle visibility. Without this reset the + // optimization would skip the rebuild entirely when the running- + // target set hasn't changed. + _lastRunningIds = []; + _lastSyncClockIds = ''; + + const perfInDom = !!document.querySelector('.dashboard-perf-persistent'); + const perfShouldBe = isSectionVisible('perf'); + + if (perfShouldBe !== perfInDom) { + // Visibility flipped — full rebuild needed (charts re-init from + // server ring buffer + immediate fetch in `initPerfCharts`). + const container = document.getElementById('dashboard-content'); + if (container) container.innerHTML = ''; + } else if (perfShouldBe) { + // Perf still visible: in-place re-render of just the + // `.perf-charts-grid` so cell visibility / order / mode / + // window / yScale changes paint immediately without the + // full-dashboard innerHTML wipe (which previously caused a + // frame of jump and a window of "—" / "0" values). + rerenderPerfGrid(); + } + + loadDashboard(true); + }, 60); +}); + // Pause uptime timer when browser tab is hidden, resume when visible document.addEventListener('visibilitychange', () => { if (document.hidden) { diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 4dd7d83..c2934c2 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -13,6 +13,7 @@ import { t } from '../core/i18n.ts'; import { dashboardPollInterval } from '../core/state.ts'; import { isActiveTab } from '../core/tab-registry.ts'; import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'; +import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts'; const MAX_SAMPLES = 120; const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps'] as const; @@ -56,7 +57,26 @@ let _fpsPeak = 60; let _fpsTargetSum = 0; let _hasGpu: boolean | null = null; let _hasTemp: boolean | null = null; -let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'; +/** Last successful /system/performance payload. Re-applied on layout + * changes so the perf grid can rebuild without flashing zeros — value + * labels stay populated until the next live poll overwrites them. */ +let _lastFetchData: any = null; +/** Cached external-setter inputs so `rerenderPerfGrid()` can repopulate + * the patches/fps/devices cells without waiting for the dashboard + * loader to fire its next pass. */ +let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null; +let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null; +let _lastDevicesArgs: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[] | null = null; +/** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy + * callers that read it directly; sync'd from the layout on every read + * via `_syncMode()`. */ +let _mode: PerfMode = (() => { + const fromLayout = (() => { try { return getGlobalConfig().perfMode; } catch { return null; } })(); + return fromLayout ?? ((localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'); +})(); +function _syncMode(): void { + try { _mode = getGlobalConfig().perfMode; } catch { /* layout not ready */ } +} function _resolveCssVar(varName: string, fallback: string): string { try { @@ -101,6 +121,9 @@ export function renderPerfModeToggle(): string { export function setPerfMode(mode: PerfMode): void { _mode = mode; localStorage.setItem(PERF_MODE_KEY, mode); + // Persist to layout so Customize panel reflects the toggle and the + // setting follows the user across browsers. + try { saveDashboardLayout(setGlobalPerfMode(getDashboardLayout(), mode)); } catch { /* layout not ready */ } document.querySelectorAll('.perf-mode-btn').forEach(btn => { btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode); @@ -118,12 +141,13 @@ export function setPerfMode(mode: PerfMode): void { /** Returns the static HTML for the perf section. */ export function renderPerfSection(): string { + _syncMode(); for (const key of CHART_KEYS) { registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); } - const card = (key: string, labelKey: string, hidden = false) => ` -
+ const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => ` +
${t(labelKey)} ${createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true })} @@ -152,9 +176,6 @@ export function renderPerfSection(): string {
`; - // Total FPS cell — aggregate throughput across all running targets. - // Layout matches the other perf cells but uses a fixed host-only - // scaling (peak is tracked in `_fpsPeak`). const fpsCell = `
@@ -169,8 +190,6 @@ export function renderPerfSection(): string {
`; - // Devices cell — N online / M configured + colored dot strip per device. - // No sparkline; the list of dots serves as its visual indicator. const devicesCell = `
@@ -187,15 +206,28 @@ export function renderPerfSection(): string {
`; - return `
- ${patchesCell} - ${fpsCell} - ${devicesCell} - ${card('cpu', 'dashboard.perf.cpu')} - ${card('ram', 'dashboard.perf.ram')} - ${card('gpu', 'dashboard.perf.gpu')} - ${card('temp', 'dashboard.perf.temp', true)} -
`; + // Cell registry — what each layout key actually renders. Cells with + // env-gated visibility (gpu, temp) start hidden and reveal themselves + // when the server reports a real reading; user can also force them + // hidden via Customize. + const cellRenderers: Record string> = { + patches: () => patchesCell, + fps: () => fpsCell, + devices: () => devicesCell, + cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false), + ram: () => sparkCard('ram', 'dashboard.perf.ram', false), + gpu: () => sparkCard('gpu', 'dashboard.perf.gpu', false), + temp: () => sparkCard('temp', 'dashboard.perf.temp', true), + }; + + let cellsHtml = ''; + for (const cell of getOrderedPerfCells()) { + if (!cell.visible) continue; + const render = cellRenderers[cell.key]; + if (render) cellsHtml += render(); + } + + return `
${cellsHtml}
`; } /** Externally-called from dashboard.ts whenever the running-target set @@ -205,6 +237,7 @@ export function updateActivePatches( running: { id: string; name: string; fps?: number }[], totalCount: number, ): void { + _lastPatchesArgs = { running: running.map(r => ({ ...r })), totalCount }; const rEl = document.getElementById('perf-patches-running'); const tEl = document.getElementById('perf-patches-total'); if (rEl) rEl.textContent = String(running.length).padStart(2, '0'); @@ -245,6 +278,7 @@ export function updateTotalFps( maxFps: number | null, targetSum: number = 0, ): void { + _lastTotalFpsArgs = { totalFps, minFps, maxFps, targetSum }; const fps = Math.max(0, totalFps); _history.fps.push(fps); if (_history.fps.length > MAX_SAMPLES) _history.fps.shift(); @@ -275,6 +309,7 @@ export function updateTotalFps( export function updateDevices( states: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[], ): void { + _lastDevicesArgs = states.map(s => ({ ...s })); const total = states.length; const online = states.filter(s => s.device_online).length; const offline = total - online; @@ -317,8 +352,18 @@ export function updateDevices( function _renderChartSvg(key: string): void { const host = document.getElementById(`perf-chart-${key}`); if (!host) return; - const sys = _history[key] || []; - const app = _appHistory[key] || []; + // Effective window (in seconds) for this cell — global default + // unless the cell pinned its own. With 1 sample/sec polling the + // window in seconds equals the desired sample count; we trim the + // module-level history arrays from the right (most recent N). + const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })(); + const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })(); + const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1; + const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec))); + const sysFull = _history[key] || []; + const appFull = _appHistory[key] || []; + const sys = sysFull.slice(-sliceN); + const app = appFull.slice(-sliceN); const color = _getColor(key); const isHostOnly = HOST_ONLY_KEYS.has(key); const showSystem = _mode === 'system' || _mode === 'both'; @@ -344,10 +389,10 @@ function _renderChartSvg(key: string): void { } if (showSystem && sys.length > 1) { - paths.push(_pathFor(sys, yMin, yMax, color, 'sys')); + paths.push(_pathFor(sys, yMin, yMax, color, 'sys', sliceN)); } if (showApp && app.length > 1) { - paths.push(_pathFor(app, yMin, yMax, color, 'app')); + paths.push(_pathFor(app, yMin, yMax, color, 'app', sliceN)); } host.innerHTML = ` @@ -363,13 +408,17 @@ function _renderChartSvg(key: string): void { } /** Build elements (area + stroke) for one series. */ -function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app'): string { +function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app', sliceN: number = MAX_SAMPLES): string { const n = history.length; if (n < 2) return ''; // Right-align so the most recent sample sits at the right edge — - // matches an instrument display where new values tick in from the right. - const step = SPARK_W / (MAX_SAMPLES - 1); - const offset = (MAX_SAMPLES - n) * step; + // matches an instrument display where new values tick in from the + // right. `sliceN` is the spark's logical sample-count "width" (set + // by the user-configurable window): a fully-populated slice fills + // the spark edge-to-edge; an early-history short array stays + // right-aligned with empty space on the left. + const step = SPARK_W / (Math.max(2, sliceN) - 1); + const offset = (sliceN - n) * step; const span = yMax - yMin || 1; const points: string[] = []; @@ -437,9 +486,23 @@ async function _fetchPerformance(): Promise { const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() }); if (!resp.ok) return; const data = await resp.json(); + _lastFetchData = data; + _applyPerfDataToDom(data, /*pushHistory=*/true); + } catch { + // Silently ignore transient fetch errors + } +} +/** Apply a `/system/performance` payload to the perf-grid DOM. + * + * Split out from `_fetchPerformance` so that on layout changes we can + * replay the cached data into a freshly-rendered grid without flashing + * "—". `pushHistory=false` skips the per-metric history push (replay + * mode); the chart sparks repaint from existing history via + * `_renderChartSvg(key)` called by the rerender helper. */ +function _applyPerfDataToDom(data: any, pushHistory: boolean): void { // CPU - _pushSample('cpu', data.cpu_percent, data.app_cpu_percent); + if (pushHistory) _pushSample('cpu', data.cpu_percent, data.app_cpu_percent); _updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0); _renderValuePair('cpu', `${data.cpu_percent.toFixed(0)}%`, @@ -453,7 +516,7 @@ async function _fetchPerformance(): Promise { const appRamPct = data.ram_total_mb > 0 ? (data.app_ram_mb / data.ram_total_mb) * 100 : 0; - _pushSample('ram', data.ram_percent, appRamPct); + if (pushHistory) _pushSample('ram', data.ram_percent, appRamPct); _updateTransportMem(data.app_ram_mb ?? 0); const usedGb = (data.ram_used_mb / 1024).toFixed(1); const totalGb = (data.ram_total_mb / 1024).toFixed(1); @@ -472,7 +535,7 @@ async function _fetchPerformance(): Promise { card.classList.remove('perf-chart-card-hint'); } } - _pushSample('temp', data.cpu_temp_c, null); + if (pushHistory) _pushSample('temp', data.cpu_temp_c, null); const batText = data.battery_temp_c != null ? `${data.battery_temp_c.toFixed(0)}°C` : null; @@ -511,7 +574,7 @@ async function _fetchPerformance(): Promise { const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb) ? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100 : null; - _pushSample('gpu', data.gpu.utilization, appGpuPct); + if (pushHistory) _pushSample('gpu', data.gpu.utilization, appGpuPct); const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`; const appText = data.gpu.app_memory_mb != null ? `${data.gpu.app_memory_mb.toFixed(0)}MB` @@ -521,14 +584,16 @@ async function _fetchPerformance(): Promise { const nameEl = document.getElementById('perf-gpu-name'); if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name; } + } else if (_hasGpu === false) { + // Cached "no GPU on this host" — hide the card after a re-render + // recreates it without the hidden attr. + const card = document.getElementById('perf-gpu-card'); + if (card) card.setAttribute('hidden', ''); } else if (_hasGpu === null) { _hasGpu = false; const card = document.getElementById('perf-gpu-card'); if (card) card.setAttribute('hidden', ''); } - } catch { - // Silently ignore transient fetch errors - } } /** Push CPU + app-CPU share to the sidebar meter plate. Called from @@ -610,10 +675,82 @@ async function _seedFromServer(): Promise { } /** Initialize perf section — paint from server-side history and wire up - * spark hover tooltips. */ + * spark hover tooltips. Also fires one immediate `_fetchPerformance` so + * the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load + * without waiting for the first poll-interval tick — otherwise the + * cards display "—" for up to ~1 second after every reload. */ export async function initPerfCharts(): Promise { await _seedFromServer(); _initSparkTooltip(); + // If we have cached data from a prior session within this tab life + // (e.g. layout-change re-render), replay it instantly. Otherwise + // hit the network once. Both paths converge before the polling + // loop starts so there's never a "—" state visible. + if (_lastFetchData) { + _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false); + } else { + await _fetchPerformance(); + } +} + +/** Re-render the perf grid in place after a layout change. + * + * Replaces just the `.perf-charts-grid` element (cell count / order / + * mode / window / yScale all read from the layout via `renderPerfSection`), + * then replays the cached state into the new DOM: + * - sparkline SVGs from the persistent `_history` arrays + * - cpu/ram/gpu/temp value labels from `_lastFetchData` + * - patches/total-fps/devices cells from cached external setter args + * + * This avoids the full-dashboard innerHTML wipe that previously caused a + * frame of layout flicker plus a window where every cell showed "0" / + * "—" until the next dashboard fetch landed. */ +export function rerenderPerfGrid(): void { + const wrapper = document.querySelector('.dashboard-perf-persistent'); + if (!wrapper) return; + const oldGrid = wrapper.querySelector('.perf-charts-grid'); + if (!oldGrid) return; + + // `renderPerfSection()` returns the entire `.perf-charts-grid` div. + const tmp = document.createElement('div'); + tmp.innerHTML = renderPerfSection(); + const newGrid = tmp.firstElementChild; + if (!newGrid) return; + oldGrid.replaceWith(newGrid); + + // Sparks: paint from existing module-level history (no flash). + for (const key of CHART_KEYS) _renderChartSvg(key); + + // Re-apply env-detection visibility (the new HTML always renders + // gpu/temp cells without the hidden attr; cached `_hasGpu/_hasTemp` + // tell us what to actually do). + if (_hasGpu === false) { + const card = document.getElementById('perf-gpu-card'); + if (card) card.setAttribute('hidden', ''); + } + if (_hasTemp === true) { + const card = document.getElementById('perf-temp-card'); + if (card) card.removeAttribute('hidden'); + } + + // Replay cached values so labels show real numbers, not "—". + if (_lastFetchData) { + _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false); + } + if (_lastPatchesArgs) { + updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount); + } + if (_lastTotalFpsArgs) { + updateTotalFps( + _lastTotalFpsArgs.totalFps, + _lastTotalFpsArgs.minFps, + _lastTotalFpsArgs.maxFps, + _lastTotalFpsArgs.targetSum, + ); + } + if (_lastDevicesArgs) { + updateDevices(_lastDevicesArgs); + } } // ─── Spark hover tooltip ───────────────────────────────────────── diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index da33d8a..a98a7a6 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -810,6 +810,55 @@ "dashboard.perf.mode.app": "App", "dashboard.perf.mode.both": "Both", "dashboard.poll_interval": "Refresh interval", + "dashboard.customize.title": "Customize Dashboard", + "dashboard.customize.presets": "Presets", + "dashboard.customize.preset.studio": "Studio", + "dashboard.customize.preset.operator": "Operator", + "dashboard.customize.preset.showrunner": "Showrunner", + "dashboard.customize.preset.diagnostics": "Diagnostics", + "dashboard.customize.preset.tv": "TV", + "dashboard.customize.modified": "Modified", + "dashboard.customize.global": "Global", + "dashboard.customize.width": "Width", + "dashboard.customize.width.full": "Full", + "dashboard.customize.width.centered": "Centered", + "dashboard.customize.width.narrow": "Narrow", + "dashboard.customize.anim": "Animations", + "dashboard.customize.anim.full": "Full", + "dashboard.customize.anim.reduced": "Reduced", + "dashboard.customize.anim.off": "Off", + "dashboard.customize.perf_mode": "Perf mode", + "dashboard.customize.sections": "Sections", + "dashboard.customize.perf_cells": "Performance Strip", + "dashboard.customize.fixed_top": "Pinned to top", + "dashboard.customize.drag_help": "Drag rows to reorder, or use the ↑/↓ buttons.", + "dashboard.customize.cell_drag_help": "Drag a row to change cell order in the Performance strip on the dashboard.", + "dashboard.customize.window": "Sample window", + "dashboard.customize.scale": "Y-axis scale", + "dashboard.customize.mode_short": "MODE", + "dashboard.customize.window_short": "WIN", + "dashboard.customize.scale_short": "SCL", + "dashboard.customize.density.comfortable": "Comfortable", + "dashboard.customize.density.compact": "Compact", + "dashboard.customize.density.dense": "Dense", + "dashboard.customize.collapse_default.on": "Start collapsed", + "dashboard.customize.collapse_default.off": "Start expanded", + "dashboard.customize.show": "Show", + "dashboard.customize.hide": "Hide", + "dashboard.customize.mode.inherit": "Inherit", + "dashboard.customize.mode.system": "Sys", + "dashboard.customize.mode.app": "App", + "dashboard.customize.mode.both": "Both", + "dashboard.customize.yscale.auto": "Auto", + "dashboard.customize.yscale.fixed": "Fixed", + "dashboard.customize.yscale.log": "Log", + "dashboard.customize.export": "Export", + "dashboard.customize.import": "Import", + "dashboard.customize.reset": "Reset", + "dashboard.customize.reset_confirm": "Reset dashboard layout to the Studio preset?", + "dashboard.customize.exported": "Layout exported", + "dashboard.customize.imported": "Layout imported", + "dashboard.customize.import_failed": "Failed to import layout", "automations.title": "Automations", "automations.empty": "No automations configured. Create one to automate scene activation.", "automations.add": "Add Automation", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index c3b2830..e3a56f1 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -791,6 +791,55 @@ "dashboard.perf.mode.app": "Приложение", "dashboard.perf.mode.both": "Оба", "dashboard.poll_interval": "Интервал обновления", + "dashboard.customize.title": "Настройка панели", + "dashboard.customize.presets": "Пресеты", + "dashboard.customize.preset.studio": "Студия", + "dashboard.customize.preset.operator": "Оператор", + "dashboard.customize.preset.showrunner": "Шоу", + "dashboard.customize.preset.diagnostics": "Диагностика", + "dashboard.customize.preset.tv": "ТВ", + "dashboard.customize.modified": "Изменено", + "dashboard.customize.global": "Общие", + "dashboard.customize.width": "Ширина", + "dashboard.customize.width.full": "Полная", + "dashboard.customize.width.centered": "По центру", + "dashboard.customize.width.narrow": "Узкая", + "dashboard.customize.anim": "Анимации", + "dashboard.customize.anim.full": "Полные", + "dashboard.customize.anim.reduced": "Снижены", + "dashboard.customize.anim.off": "Выкл", + "dashboard.customize.perf_mode": "Режим перф.", + "dashboard.customize.sections": "Секции", + "dashboard.customize.perf_cells": "Системный мониторинг", + "dashboard.customize.fixed_top": "Закреплено сверху", + "dashboard.customize.drag_help": "Перетащите строки или используйте ↑/↓.", + "dashboard.customize.cell_drag_help": "Перетащите строку, чтобы изменить порядок ячеек в полосе производительности.", + "dashboard.customize.window": "Окно выборки", + "dashboard.customize.scale": "Шкала Y", + "dashboard.customize.mode_short": "РЕЖ", + "dashboard.customize.window_short": "ОКН", + "dashboard.customize.scale_short": "ШКЛ", + "dashboard.customize.density.comfortable": "Просторно", + "dashboard.customize.density.compact": "Компактно", + "dashboard.customize.density.dense": "Плотно", + "dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию", + "dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию", + "dashboard.customize.show": "Показать", + "dashboard.customize.hide": "Скрыть", + "dashboard.customize.mode.inherit": "Наслед.", + "dashboard.customize.mode.system": "Сис", + "dashboard.customize.mode.app": "Прил", + "dashboard.customize.mode.both": "Оба", + "dashboard.customize.yscale.auto": "Авто", + "dashboard.customize.yscale.fixed": "Фикс.", + "dashboard.customize.yscale.log": "Лог.", + "dashboard.customize.export": "Экспорт", + "dashboard.customize.import": "Импорт", + "dashboard.customize.reset": "Сбросить", + "dashboard.customize.reset_confirm": "Сбросить настройки панели к пресету «Студия»?", + "dashboard.customize.exported": "Настройки экспортированы", + "dashboard.customize.imported": "Настройки импортированы", + "dashboard.customize.import_failed": "Не удалось импортировать настройки", "automations.title": "Автоматизации", "automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.", "automations.add": "Добавить автоматизацию", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 64669a2..fedb6e4 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -791,6 +791,55 @@ "dashboard.perf.mode.app": "应用", "dashboard.perf.mode.both": "全部", "dashboard.poll_interval": "刷新间隔", + "dashboard.customize.title": "自定义仪表盘", + "dashboard.customize.presets": "预设", + "dashboard.customize.preset.studio": "工作室", + "dashboard.customize.preset.operator": "操作员", + "dashboard.customize.preset.showrunner": "演出", + "dashboard.customize.preset.diagnostics": "诊断", + "dashboard.customize.preset.tv": "电视", + "dashboard.customize.modified": "已修改", + "dashboard.customize.global": "全局", + "dashboard.customize.width": "宽度", + "dashboard.customize.width.full": "全宽", + "dashboard.customize.width.centered": "居中", + "dashboard.customize.width.narrow": "窄", + "dashboard.customize.anim": "动画", + "dashboard.customize.anim.full": "完整", + "dashboard.customize.anim.reduced": "减少", + "dashboard.customize.anim.off": "关闭", + "dashboard.customize.perf_mode": "性能模式", + "dashboard.customize.sections": "分区", + "dashboard.customize.perf_cells": "性能面板", + "dashboard.customize.fixed_top": "固定在顶部", + "dashboard.customize.drag_help": "拖动行重新排序,或使用 ↑/↓ 按钮。", + "dashboard.customize.cell_drag_help": "拖动行可更改仪表盘性能条中单元格的顺序。", + "dashboard.customize.window": "采样窗口", + "dashboard.customize.scale": "Y 轴刻度", + "dashboard.customize.mode_short": "模式", + "dashboard.customize.window_short": "窗口", + "dashboard.customize.scale_short": "刻度", + "dashboard.customize.density.comfortable": "宽松", + "dashboard.customize.density.compact": "紧凑", + "dashboard.customize.density.dense": "密集", + "dashboard.customize.collapse_default.on": "默认折叠", + "dashboard.customize.collapse_default.off": "默认展开", + "dashboard.customize.show": "显示", + "dashboard.customize.hide": "隐藏", + "dashboard.customize.mode.inherit": "继承", + "dashboard.customize.mode.system": "系统", + "dashboard.customize.mode.app": "应用", + "dashboard.customize.mode.both": "两者", + "dashboard.customize.yscale.auto": "自动", + "dashboard.customize.yscale.fixed": "固定", + "dashboard.customize.yscale.log": "对数", + "dashboard.customize.export": "导出", + "dashboard.customize.import": "导入", + "dashboard.customize.reset": "重置", + "dashboard.customize.reset_confirm": "将仪表盘布局重置为「工作室」预设?", + "dashboard.customize.exported": "布局已导出", + "dashboard.customize.imported": "布局已导入", + "dashboard.customize.import_failed": "导入布局失败", "automations.title": "自动化", "automations.empty": "尚未配置自动化。创建一个以自动激活场景。", "automations.add": "添加自动化", diff --git a/server/tests/test_preferences_api.py b/server/tests/test_preferences_api.py new file mode 100644 index 0000000..d9181c3 --- /dev/null +++ b/server/tests/test_preferences_api.py @@ -0,0 +1,145 @@ +"""Tests for /api/v1/preferences/dashboard-layout endpoints.""" + +import pytest + +from ledgrab.config import get_config + +_config = get_config() +_api_key = next(iter(_config.auth.api_keys.values()), "") +AUTH_HEADERS = {"Authorization": f"Bearer {_api_key}"} if _api_key else {} + + +@pytest.fixture(scope="module") +def client(): + from fastapi.testclient import TestClient + + from ledgrab.main import app + + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +def _minimal_layout() -> dict: + return { + "version": 1, + "sections": [ + { + "key": "perf", + "visible": True, + "collapsedDefault": False, + "density": "comfortable", + "options": {}, + }, + ], + "perfCells": [ + { + "key": "cpu", + "visible": True, + "mode": "inherit", + "span": 1, + "window": 120, + "yScale": "auto", + "precision": 1, + "showSubtitle": True, + "showRefLine": True, + }, + ], + "global": { + "width": "full", + "accent": "target", + "animations": "full", + "emptyState": "hide", + "toolbarPosition": "top", + "autoCollapseRunningEmpty": False, + "showTutorial": True, + "perfMode": "both", + "pollMs": 1000, + }, + } + + +def test_get_dashboard_layout_default_empty(client): + """When no layout has been saved, GET returns an empty object.""" + # Clear first so this test is order-independent. + client.delete("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS) + resp = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS) + assert resp.status_code == 200 + assert resp.json() == {} + + +def test_put_then_get_dashboard_layout(client): + """PUT a layout, GET it back unchanged.""" + layout = _minimal_layout() + put = client.put( + "/api/v1/preferences/dashboard-layout", + json=layout, + headers=AUTH_HEADERS, + ) + assert put.status_code == 200 + assert put.json() == {"ok": True} + + got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS) + assert got.status_code == 200 + body = got.json() + assert body["version"] == 1 + assert body["sections"][0]["key"] == "perf" + assert body["perfCells"][0]["key"] == "cpu" + assert body["global"]["perfMode"] == "both" + + +def test_put_rejects_missing_version(client): + """Body without numeric version field is rejected with 422.""" + bad = {"sections": []} + resp = client.put( + "/api/v1/preferences/dashboard-layout", + json=bad, + headers=AUTH_HEADERS, + ) + assert resp.status_code == 422 + + +def test_put_rejects_non_object(client): + """Bare arrays / strings / numbers are rejected by FastAPI body validation.""" + resp = client.put( + "/api/v1/preferences/dashboard-layout", + json=["not", "an", "object"], + headers=AUTH_HEADERS, + ) + assert resp.status_code in (400, 422) + + +def test_delete_clears_layout(client): + """DELETE wipes the saved layout so subsequent GET returns empty.""" + client.put( + "/api/v1/preferences/dashboard-layout", + json=_minimal_layout(), + headers=AUTH_HEADERS, + ) + deleted = client.delete( + "/api/v1/preferences/dashboard-layout", + headers=AUTH_HEADERS, + ) + assert deleted.status_code == 200 + after = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS) + assert after.status_code == 200 + assert after.json() == {} + + +def test_layout_round_trip_preserves_unknown_fields(client): + """Frontend may add new keys (e.g. v1.1 sections) — backend must + pass them through verbatim, not strip them.""" + layout = _minimal_layout() + layout["futureField"] = {"foo": "bar"} + layout["sections"].append( + { + "key": "audio-meters", + "visible": True, + "collapsedDefault": False, + "density": "comfortable", + "options": {"sensitivity": 0.7}, + } + ) + client.put("/api/v1/preferences/dashboard-layout", json=layout, headers=AUTH_HEADERS) + got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS).json() + assert got["futureField"] == {"foo": "bar"} + assert any(s["key"] == "audio-meters" for s in got["sections"]) From b43e1cf3759e9752b82d5330fb05c6a5e1cceece Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 02:27:38 +0300 Subject: [PATCH 05/11] feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the remaining tabs in line with the Channels-tab visual language: - .template-card now mirrors .card and .dashboard-target — channel stripe on the left edge with glow, silkscreened corner bracket top-right, hairline border on --lux-bg-1, hover lift + stripe widen-and-glow. Covers streams, capture / pp / cspt / pattern / audio templates and every Integrations card (HA / MQTT / weather / value / sync clocks / game integrations). - Channel mapping extended in cards.css. Direct attribute hooks for the per-domain ids; section-scoped hooks via [data-card-section="…"] for the cards that share a generic data-id (HA / MQTT / weather / value → cyan, game-integrations → amber, sync-clocks → violet, HA-light-targets → signal). No JS changes — uses the section markup CardSection.render already emits. - Graph editor nodes pick up the studio-console palette: --lux-bg-1 fill with hairline stroke, hover bold-line, selected/running stroke --ch-signal with drop-shadow glow. Title font moved off Big Shoulders Display (which read as "stretched" at 12 px) onto --font-body (Manrope); subtitle keeps the mono-uppercase caption treatment with a conservative letter-spacing. Running gradient now rides the channel palette (signal → cyan → signal) rather than the legacy primary / success colours. Port labels and grid dots adopt --lux-line tokens. - Graph node titles get real text-overflow:ellipsis behaviour. SVG can't do that natively, so renderNodes runs a post-mount fit pass that binary-searches the longest character prefix that fits inside the clip rect (with 2 px slack), suffixed with "…". Trailing whitespace is stripped before the ellipsis so we never get "Foo …". Full text is stashed on data-full-text so the fit can be re-run on re-renders. Also bundles two perf-charts fixes from the same session: - Hover regression — listener was bound to .perf-charts-grid, which rerenderPerfGrid() replaces. Moved to document.body with a guard, and the cursor → sample math now uses the same sliceN as the spark rendering so the tooltip stays accurate when the user changes the window setting. - Color picker on every perf cell. Patches / Total FPS / Devices now expose the same color picker as the spark cells; defaults added to METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so saved colours apply immediately, including across rerenderPerfGrid. --- TODO.md | 23 ++++ server/src/ledgrab/static/css/cards.css | 40 +++++++ .../src/ledgrab/static/css/graph-editor.css | 64 ++++++----- server/src/ledgrab/static/css/streams.css | 46 +++++++- .../src/ledgrab/static/js/core/graph-nodes.ts | 56 +++++++++- .../static/js/features/graph-editor.ts | 6 +- .../ledgrab/static/js/features/perf-charts.ts | 105 ++++++++++++------ 7 files changed, 269 insertions(+), 71 deletions(-) diff --git a/TODO.md b/TODO.md index 46a2a16..a087383 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,29 @@ Phases are independent and CSS-only where possible — backend untouched. tabs. - [x] Graph editor — toolbar gets a gradient background + hairline + rack shadow + backdrop blur. Canvas and nodes untouched. +- [x] `.template-card` — Lumenworks treatment (channel stripe on left, + corner bracket top-right, hairline border, hover lift + stripe + glow). Brings Inputs (streams / capture / pp / cspt / pattern + templates) and Integrations (HA / MQTT / weather / value / + sync-clock / game-integration cards) up to the same visual + language as `.card` and `.dashboard-target`. +- [x] `cards.css` — channel mapping extended to `.template-card`. + Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id` + (cyan), `data-cspt-id`/`data-pattern-template-id` (signal), + `data-audio-template-id`/`data-apt-id` (magenta). Section-scoped + hooks via `[data-card-section="…"]` for cards that share a + generic `data-id` (HA / MQTT / weather / value → cyan; + game-integrations → amber; sync-clocks → violet; HA-light-targets + → signal). No JS changes — uses the section markup `CardSection` + already emits. +- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke, + hover bold-line, selected/running stroke `--ch-signal` with + drop-shadow glow. Title font switched from DM Sans to + `--font-display`; subtitle to mono uppercase wide-tracking. + Port-drop-target glow recoloured to `--ch-signal`. Port labels + adopt the mono caption treatment. Grid dots use `--lux-line`. + Running gradient stops switched from `--primary-color`/`--success-color` + to channel palette (signal → cyan → signal). ### Phase 5 — Modal restyle diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 789af28..050c1ff 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -207,6 +207,46 @@ section { .card[data-card-type="offline"], .card.ch-coral { --ch: var(--ch-coral, var(--danger-color)); } +/* ── Channel mapping for `.template-card` ── + * Cards rendered by `wrapCard({ type: 'template-card' })` are used by the + * Inputs and Integrations tabs (plus a few other CardSection consumers). + * Many of those use a generic `data-id` attribute, so we scope by the + * parent section's `data-card-section` instead of relying on a unique + * data-attr per row. Direct attribute hooks come first for the cards that + * already carry a domain-specific id. + */ + +/* Direct attribute hooks (Inputs tab — known per-domain attrs) */ +.template-card[data-stream-id], +.template-card[data-template-id], +.template-card[data-pp-template-id] { --ch: var(--ch-cyan, var(--info-color)); } + +.template-card[data-cspt-id], +.template-card[data-pattern-template-id] { --ch: var(--ch-signal, var(--primary-color)); } + +.template-card[data-audio-template-id], +.template-card[data-apt-id] { --ch: var(--ch-magenta, #ff4ade); } + +/* Section-scoped hooks (cards that share `data-id` and need their channel + * resolved via the surrounding section). Matches `
` emitted by `CardSection.render`. */ + +/* Network / data-input integrations → cyan (input language) */ +[data-card-section="ha-sources"] .template-card[data-id], +[data-card-section="mqtt-sources"] .template-card[data-id], +[data-card-section="weather-sources"] .template-card[data-id], +[data-card-section="value-sources"] .template-card[data-id] { --ch: var(--ch-cyan, var(--info-color)); } + +/* Game integrations → amber (events / surfaces) */ +[data-card-section="game-integrations"] .template-card[data-id], +.template-card[data-gi-id] { --ch: var(--ch-amber, var(--warning-color)); } + +/* Sync clocks → violet (timing / orchestration, mirrors automation/scenes) */ +[data-card-section="sync-clocks"] .template-card[data-id] { --ch: var(--ch-violet, #8b7eff); } + +/* HA light targets → signal (output target, mirrors led-targets) */ +[data-card-section="ha-light-targets"] .template-card[data-ha-target-id] { --ch: var(--ch-signal, var(--primary-color)); } + /* ── Card glare effect ── */ .card-glare::after, .template-card.card-glare::after, diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index ecb73c0..d6e3bfa 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -410,8 +410,8 @@ html:has(#tab-graph.active) { /* ── Grid background ── */ .graph-grid-dot { - fill: var(--border-color); - opacity: 0.3; + fill: var(--lux-line, var(--border-color)); + opacity: 0.32; } /* ── Node styles ── */ @@ -430,21 +430,24 @@ html:has(#tab-graph.active) { } .graph-node-body { - fill: var(--card-bg); - stroke: none; - rx: 8; - ry: 8; - transition: stroke 0.15s; + fill: var(--lux-bg-1, var(--card-bg)); + stroke: var(--lux-line, var(--border-color)); + stroke-width: 1; + rx: 6; + ry: 6; + transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease; } .graph-node:hover .graph-node-body { - stroke: var(--text-secondary); + stroke: var(--lux-line-bold, var(--text-secondary)); stroke-width: 1; + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25)); } .graph-node.selected .graph-node-body { - stroke: var(--primary-color); + stroke: var(--ch-signal, var(--primary-color)); stroke-width: 2; + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } .graph-node-color-bar { @@ -459,37 +462,45 @@ html:has(#tab-graph.active) { } .graph-node-title { - fill: var(--text-color); + fill: var(--lux-ink, var(--text-color)); font-size: 12px; font-weight: 600; - font-family: 'DM Sans', sans-serif; + /* Body font, not display — Big Shoulders is condensed and reads as + * "stretched" at 12 px in a node label. Display font is for hero + * headers only. */ + font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif); + letter-spacing: 0; } .graph-node-subtitle { - fill: var(--text-secondary); - font-size: 10px; - font-family: 'DM Sans', sans-serif; + fill: var(--lux-ink-dim, var(--text-secondary)); + font-size: 9.5px; + font-weight: 600; + font-family: var(--font-mono, monospace); + letter-spacing: 0.04em; + text-transform: uppercase; } .graph-node-icon { - stroke: var(--text-muted); + stroke: var(--lux-ink-mute, var(--text-muted)); fill: none; - stroke-width: 2; + stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; - opacity: 0.5; + opacity: 0.55; } .graph-node.running .graph-node-icon { - stroke: var(--primary-color); - opacity: 0.85; + stroke: var(--ch-signal, var(--primary-color)); + opacity: 0.95; } -/* ── Running indicator (animated gradient border) ── */ +/* ── Running indicator (animated gradient border + signal-flow glow) ── */ .graph-node.running .graph-node-body { stroke: url(#running-gradient); stroke-width: 2; + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } @keyframes graph-running-rotate { @@ -534,13 +545,16 @@ html:has(#tab-graph.active) { /* Port labels — hidden by default, shown on node hover, positioned outside node */ .graph-port-label { font-size: 9px; - font-weight: 600; - fill: var(--text-color); + font-weight: 700; + font-family: var(--font-mono, monospace); + letter-spacing: 0.08em; + text-transform: uppercase; + fill: var(--lux-ink-dim, var(--text-color)); pointer-events: none; opacity: 0; transition: opacity 0.15s; paint-order: stroke fill; - stroke: var(--bg-color); + stroke: var(--lux-bg-0, var(--bg-color)); stroke-width: 3px; stroke-linejoin: round; } @@ -569,9 +583,9 @@ html:has(#tab-graph.active) { .graph-port-drop-target { r: 7 !important; - stroke: var(--primary-color) !important; + stroke: var(--ch-signal, var(--primary-color)) !important; stroke-width: 3 !important; - filter: drop-shadow(0 0 6px var(--primary-color)); + filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color))); } /* ── Edges ── */ diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index f565b74..014959b 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -9,19 +9,53 @@ } .template-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px; - transition: box-shadow 0.2s ease, transform 0.2s ease; + --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, var(--radius-md)); + padding: 18px 20px 16px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; display: flex; flex-direction: column; position: relative; + overflow: hidden; +} + +/* Channel stripe on left edge — colour-coded per entity type via --ch override */ +.template-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--ch); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent); + pointer-events: none; + z-index: 1; + transition: width 0.2s ease, box-shadow 0.2s ease; +} + +/* Corner bracket — silkscreened panel feel in the top-right */ +.template-card::after { + content: ''; + position: absolute; + top: 8px; right: 8px; + width: 12px; height: 12px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + pointer-events: none; + opacity: 0.7; + z-index: 1; } .template-card:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color)); transform: translateY(-2px); + border-color: var(--lux-line-bold, var(--border-color)); +} + +.template-card:hover::before { + width: 4px; + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent); } .add-template-card { diff --git a/server/src/ledgrab/static/js/core/graph-nodes.ts b/server/src/ledgrab/static/js/core/graph-nodes.ts index f61781f..b6d5b60 100644 --- a/server/src/ledgrab/static/js/core/graph-nodes.ts +++ b/server/src/ledgrab/static/js/core/graph-nodes.ts @@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map, for (const node of nodeMap.values()) { const g = renderNode(node, callbacks); group.appendChild(g); + // Now that the is in the live SVG, `getComputedTextLength()` + // returns real values — fit the title/subtitle to the visible + // text area and append "…" if they overflow. + _fitNodeText(g, node.width); } } +/** Available text width per node — clip rect is x=14..(width-48) wide and + * text starts at x=16, so the usable run is `width - 50`. The 2 px slack + * on the right keeps the ellipsis from kissing the clip edge. */ +function _availableTextWidth(nodeWidth: number): number { + return Math.max(0, nodeWidth - 52); +} + +/** Replace the text of an SVG `` element with the longest prefix of + * its `data-full-text` that fits within `maxWidth`, suffixed with "…". + * No-op if the full text already fits. */ +function _fitTextToWidth(el: SVGTextElement, maxWidth: number): void { + const full = el.getAttribute('data-full-text') || el.textContent || ''; + el.textContent = full; + if (maxWidth <= 0) { el.textContent = ''; return; } + let len = 0; + try { len = el.getComputedTextLength(); } catch { return; } + if (len <= maxWidth) return; + + // Binary search for the longest character prefix that fits with "…". + let lo = 0, hi = full.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + el.textContent = full.slice(0, mid).trimEnd() + '…'; + try { + if (el.getComputedTextLength() <= maxWidth) lo = mid; + else hi = mid - 1; + } catch { + return; + } + } + el.textContent = (full.slice(0, lo).trimEnd() || '') + '…'; +} + +function _fitNodeText(nodeG: Element, nodeWidth: number): void { + const maxW = _availableTextWidth(nodeWidth); + const title = nodeG.querySelector('.graph-node-title'); + const subtitle = nodeG.querySelector('.graph-node-subtitle'); + if (title) _fitTextToWidth(title, maxW); + if (subtitle) _fitTextToWidth(subtitle, maxW); +} + /** * Render a single node. */ @@ -342,23 +387,30 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement { clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height })); g.appendChild(clipPath); - // Title (shift left edge for icon to have room) + // Title (shift left edge for icon to have room). + // Full text is stashed on `data-full-text` so the post-mount fit pass + // can measure with `getComputedTextLength()` and binary-search the + // longest prefix that fits, appending "…" instead of relying on the + // clip-path (which silently chops mid-glyph with no ellipsis cue). const title = svgEl('text', { class: 'graph-node-title', x: 16, y: 24, 'clip-path': `url(#${clipId})`, + 'data-full-text': name, }); title.textContent = name; g.appendChild(title); // Subtitle (type) if (subtype) { + const subText = subtype.replace(/_/g, ' '); const sub = svgEl('text', { class: 'graph-node-subtitle', x: 16, y: 42, 'clip-path': `url(#${clipId})`, + 'data-full-text': subText, }); - sub.textContent = subtype.replace(/_/g, ' '); + sub.textContent = subText; g.appendChild(sub); } diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index e796079..9ddf4f8 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -1140,9 +1140,9 @@ function _graphHTML(): string { - - - + + + diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index c2934c2..8a4bce6 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -17,6 +17,10 @@ import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardL const MAX_SAMPLES = 120; const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps'] as const; +/** Every cell key the user can color-customize, including the + * patches / devices cells that don't have sparklines but still + * carry a header accent stripe. */ +const ALL_COLORABLE_KEYS = ['patches', 'fps', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const; const PERF_MODE_KEY = 'perfMetricsMode'; const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio) const SPARK_H = 64; @@ -28,20 +32,24 @@ const HOST_ONLY_KEYS = new Set(['temp', 'fps']); perf cards share the same language as the rest of the app. Overrides per-user in localStorage still honoured by `_getColor`. */ const METRIC_CSS_VARS: Record = { - cpu: '--ch-coral', - ram: '--ch-violet', - gpu: '--ch-signal', - temp: '--ch-amber', - fps: '--ch-cyan', + patches: '--ch-magenta', + fps: '--ch-cyan', + devices: '--ch-signal', + cpu: '--ch-coral', + ram: '--ch-violet', + gpu: '--ch-signal', + temp: '--ch-amber', }; /** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */ const METRIC_FALLBACK: Record = { - cpu: '#FF6B6B', - ram: '#A855F7', - gpu: '#10B981', - temp: '#FCD34D', - fps: '#00D8FF', + patches: '#EC4899', + fps: '#00D8FF', + devices: '#10B981', + cpu: '#FF6B6B', + ram: '#A855F7', + gpu: '#10B981', + temp: '#FCD34D', }; type PerfMode = 'system' | 'app' | 'both'; @@ -142,14 +150,25 @@ export function setPerfMode(mode: PerfMode): void { /** Returns the static HTML for the perf section. */ export function renderPerfSection(): string { _syncMode(); - for (const key of CHART_KEYS) { + for (const key of ALL_COLORABLE_KEYS) { registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); } + /** Color-picker widget rendered next to each cell's label. Even + * cells without sparklines (patches/devices) get one — it drives + * the card's `--perf-accent` CSS var for the header stripe. */ + const colorWidget = (key: string) => createColorPicker({ + id: `perf-${key}`, + currentColor: _getColor(key), + onPick: undefined, + anchor: 'left', + showReset: true, + }); + const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => ` -
+
- ${t(labelKey)} ${createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true })} + ${t(labelKey)} ${colorWidget(key)}
@@ -162,9 +181,9 @@ export function renderPerfSection(): string {
`; const patchesCell = ` -
+
- ${t('dashboard.perf.active_patches') || 'Active Patches'} + ${t('dashboard.perf.active_patches') || 'Active Patches'} ${colorWidget('patches')}
@@ -177,9 +196,9 @@ export function renderPerfSection(): string {
`; const fpsCell = ` -
+
- ${t('dashboard.perf.total_fps') || 'Total FPS'} + ${t('dashboard.perf.total_fps') || 'Total FPS'} ${colorWidget('fps')}
@@ -191,9 +210,9 @@ export function renderPerfSection(): string {
`; const devicesCell = ` -
+
- ${t('dashboard.perf.devices') || 'Devices'} + ${t('dashboard.perf.devices') || 'Devices'} ${colorWidget('devices')}
@@ -790,16 +809,19 @@ function _metricLabel(key: string): string { return key.toUpperCase(); } +let _tooltipBound = false; function _initSparkTooltip(): void { + if (_tooltipBound) return; + _tooltipBound = true; const intervalMs = dashboardPollInterval || 2000; - // Event-delegate from .perf-charts-grid so re-renders of the perf - // section don't require re-binding per spark. - const grid = document.querySelector('.perf-charts-grid'); - if (!grid) return; - - grid.addEventListener('mousemove', (rawEv) => { + // Bound on `document.body` instead of `.perf-charts-grid` so the + // listener survives `rerenderPerfGrid()` replacing the grid element. + // The handler bails out unless the cursor is actually over a spark, + // so the hot-path cost is just one `closest()` call per mousemove. + document.body.addEventListener('mousemove', (rawEv) => { const ev = rawEv as MouseEvent; const target = ev.target as HTMLElement; + if (!target || !target.closest) { _hideTooltip(); return; } const spark = target.closest('.perf-chart-spark') as HTMLElement | null; if (!spark) { _hideTooltip(); return; } const card = spark.closest('.perf-chart-card') as HTMLElement | null; @@ -808,19 +830,30 @@ function _initSparkTooltip(): void { if (!key || !_history[key]) { _hideTooltip(); return; } const rect = spark.getBoundingClientRect(); - const sys = _history[key]; - const app = _appHistory[key]; - if (sys.length < 2) { _hideTooltip(); return; } + const sysFull = _history[key]; + const appFull = _appHistory[key]; + if (sysFull.length < 2) { _hideTooltip(); return; } + + // Tooltip must read from the same slice the spark draws — otherwise + // the cursor x → sample mapping skews after the user changes the + // window setting. Effective window in seconds × samples-per-sec + // (clamped to MAX_SAMPLES) gives the visible slice length. + const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })(); + const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })(); + const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1; + const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec))); + const sys = sysFull.slice(-sliceN); + const app = appFull.slice(-sliceN); // Samples right-align in the spark (new tick arrives at the right // edge), so cursor x → index in the last-N window. const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left)); const fraction = rect.width > 0 ? relX / rect.width : 0; - // The visible series maps to the rightmost sys.length samples in - // a MAX_SAMPLES-wide viewBox — compute which actual sample the - // cursor x corresponds to. - const visibleStart = MAX_SAMPLES - sys.length; - const globalIdx = Math.round(fraction * (MAX_SAMPLES - 1)); + // The visible series maps to the rightmost `sys.length` samples in + // a `sliceN`-wide spark — compute which actual sample the cursor x + // corresponds to. + const visibleStart = sliceN - sys.length; + const globalIdx = Math.round(fraction * (sliceN - 1)); const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart)); const sysValue = sys[localIdx]; const appValue = app && app.length > localIdx ? app[localIdx] : null; @@ -862,8 +895,10 @@ function _initSparkTooltip(): void { _tooltipMarkerEl.style.setProperty('--marker-color', color); } }); - - grid.addEventListener('mouseleave', _hideTooltip); + // Hide whenever the mouse leaves the body (out of viewport) — when + // moving between sparks within the body, the mousemove handler above + // re-positions the tip on the next sample. + document.body.addEventListener('mouseleave', _hideTooltip); } function _hideTooltip(): void { From dd415e2813988107fc912a6d064b50e1c4ed0341 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 02:42:57 +0300 Subject: [PATCH 06/11] fix(ui): cards on pure black/white, decoupled from bg-anim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes after the Phase-4 migration landed: - `--card-bg` flipped from `#101216` / `#f5f6f8` to pure `#000000` / `#ffffff` in base.css. Off-pure greys read as muddy when sitting on a pure-black/white page background; pure values keep card surfaces flush with the rest of the chrome and let the channel stripe + corner bracket carry all the visual differentiation. - Removed the `[data-bg-anim="on"] .card { background: rgba(...) }` block that turned every entity card translucent whenever the WebGL background was enabled. Card backgrounds are now stable across the toggle — the shader bleeds through `body { background: transparent }` only, not through cards. The same card now reads identically with the shader on or off. - WebGL shader base colour (`_bgColor` in bg-anim.ts and bg-shaders.ts) was using the legacy mid-grey `#1a1a1a` / `#f5f5f5`. That added a constant grey haze under the additive accent glow that didn't exist on the surrounding pure-black/white page. Switched to `[0,0,0]` / `[1,1,1]` so the shader composes against the same base as the page. - Reverted two leftovers from the Phase-4 commit where I had migrated `.template-card` and `.graph-node-body` away from `var(--card-bg)` toward `var(--lux-bg-1, …)`. Those backgrounds now live on `var(--card-bg)` again, matching every other migrated card. --- server/src/ledgrab/static/css/base.css | 24 ++++++------------- .../src/ledgrab/static/css/graph-editor.css | 2 +- server/src/ledgrab/static/css/streams.css | 2 +- server/src/ledgrab/static/js/core/bg-anim.ts | 9 +++++-- .../src/ledgrab/static/js/core/bg-shaders.ts | 8 +++++-- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/server/src/ledgrab/static/css/base.css b/server/src/ledgrab/static/css/base.css index f3f91a5..f6b1592 100644 --- a/server/src/ledgrab/static/css/base.css +++ b/server/src/ledgrab/static/css/base.css @@ -101,7 +101,7 @@ [data-theme="dark"] { --bg-color: #000000; --bg-secondary: #0a0b0d; - --card-bg: #101216; + --card-bg: #000000; --text-color: #e0e0e0; --text-primary: #e0e0e0; --text-secondary: #999; @@ -148,7 +148,7 @@ [data-theme="light"] { --bg-color: #ffffff; --bg-secondary: #fafbfc; - --card-bg: #f5f6f8; + --card-bg: #ffffff; --text-color: #333333; --text-primary: #333333; --text-secondary: #595959; @@ -241,21 +241,11 @@ html.modal-open { background: transparent; } -/* When bg-anim is active, make entity cards slightly translucent - so the shader bleeds through. Only target cards — NOT modals, - pickers, tab bars, headers, or other chrome. */ -[data-bg-anim="on"][data-theme="dark"] .card, -[data-bg-anim="on"][data-theme="dark"] .template-card, -[data-bg-anim="on"][data-theme="dark"] .add-device-card, -[data-bg-anim="on"][data-theme="dark"] .dashboard-target { - background: rgba(45, 45, 45, 0.88); -} -[data-bg-anim="on"][data-theme="light"] .card, -[data-bg-anim="on"][data-theme="light"] .template-card, -[data-bg-anim="on"][data-theme="light"] .add-device-card, -[data-bg-anim="on"][data-theme="light"] .dashboard-target { - background: rgba(255, 255, 255, 0.85); -} +/* Card backgrounds are intentionally stable across the dynamic-bg + toggle — the shader bleeds through the page background only. + (Previously a translucent override let the shader show through + cards too, but it made the same card look different depending on + whether the user had the WebGL background enabled.) */ /* Blur behind header via pseudo-element — applying backdrop-filter directly to header would create a containing block and break position:fixed on the .tab-bar nested inside it (mobile bottom nav). */ diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index d6e3bfa..36a8e74 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -430,7 +430,7 @@ html:has(#tab-graph.active) { } .graph-node-body { - fill: var(--lux-bg-1, var(--card-bg)); + fill: var(--card-bg); stroke: var(--lux-line, var(--border-color)); stroke-width: 1; rx: 6; diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 014959b..cf867d5 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -10,7 +10,7 @@ .template-card { --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ - background: var(--lux-bg-1, var(--card-bg)); + background: var(--card-bg); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-md, var(--radius-md)); padding: 18px 20px 16px; diff --git a/server/src/ledgrab/static/js/core/bg-anim.ts b/server/src/ledgrab/static/js/core/bg-anim.ts index a9aa5b6..3e61aab 100644 --- a/server/src/ledgrab/static/js/core/bg-anim.ts +++ b/server/src/ledgrab/static/js/core/bg-anim.ts @@ -114,7 +114,10 @@ let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for let _raf: number | null = null; let _startTime = 0; let _accent = [76 / 255, 175 / 255, 80 / 255]; -let _bgColor = [26 / 255, 26 / 255, 26 / 255]; +// Base canvas colour — must match `--bg-color` (pure black / white in the +// Lumenworks theme). Using mid-greys here washes the additive glow with a +// constant tint that doesn't exist on the surrounding page background. +let _bgColor = [0, 0, 0]; let _isLight = 0.0; // Particle state (CPU-side, positions in 0..1 UV space) @@ -262,7 +265,9 @@ export function updateBgAnimAccent(hex: string): void { } export function updateBgAnimTheme(isDark: boolean): void { - _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; + // Match the page's `--bg-color` (pure black/white) — see comment on + // the `_bgColor` declaration above. + _bgColor = isDark ? [0, 0, 0] : [1, 1, 1]; _isLight = isDark ? 0.0 : 1.0; } diff --git a/server/src/ledgrab/static/js/core/bg-shaders.ts b/server/src/ledgrab/static/js/core/bg-shaders.ts index df1b82f..b95d0a9 100644 --- a/server/src/ledgrab/static/js/core/bg-shaders.ts +++ b/server/src/ledgrab/static/js/core/bg-shaders.ts @@ -320,7 +320,10 @@ let _uBg: WebGLUniformLocation | null = null; let _uLight: WebGLUniformLocation | null = null; let _accent = [76 / 255, 175 / 255, 80 / 255]; -let _bgColor = [26 / 255, 26 / 255, 26 / 255]; +// Base canvas colour — must match `--bg-color` (pure black / white in +// the Lumenworks theme). Using mid-greys here washes the additive glow +// with a constant tint that doesn't exist on the surrounding page bg. +let _bgColor = [0, 0, 0]; let _isLight = 0.0; // ─── GL helpers ────────────────────────────────────────────── @@ -471,7 +474,8 @@ export function updateShaderAccent(hex: string): void { /** Update theme brightness (called on theme toggle). */ export function updateShaderTheme(isDark: boolean): void { - _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; + // Match the page's `--bg-color` token (pure black/white). + _bgColor = isDark ? [0, 0, 0] : [1, 1, 1]; _isLight = isDark ? 0.0 : 1.0; } From 2bae3041072736b34b5cf381894397804847249a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 13:54:18 +0300 Subject: [PATCH 07/11] fix(ui): single-row header + readable sidebar labels at narrow widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At ≤1100px the header grid only declared 3 tracks for 4 children, so the toolbar wrapped to a second row, doubling the header height. Add a 4th track, tighten the meta cluster, and hide non-essential toolbar items (API link, tour-restart) so everything fits in one row. At ≤900px drop CPU/Mem cells (Uptime + Poll remain) so the toolbar still fits beside the meta cluster. Sidebar tab captions on the 56 px icon rail were ellipsis-truncated to "DASHBO…" / "AUTOMA…" / "INTEGR…". Switch to a 2-line clamp with tighter font/tracking so each label renders in full. --- server/src/ledgrab/static/css/layout.css | 49 ++++++++++++++++++++++- server/src/ledgrab/static/css/sidebar.css | 19 ++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/server/src/ledgrab/static/css/layout.css b/server/src/ledgrab/static/css/layout.css index bf8d163..03f7674 100644 --- a/server/src/ledgrab/static/css/layout.css +++ b/server/src/ledgrab/static/css/layout.css @@ -1120,8 +1120,10 @@ h2 { the sidebar hides entirely and mobile.css reverts .tab-bar to a fixed bottom strip. */ @media (max-width: 1100px) { + /* Keep all four header children (title | center | meta | toolbar) on one + row. Without an explicit 4th track they wrap, doubling the header. */ header { - grid-template-columns: var(--sidebar-width, 56px) 1fr auto; + grid-template-columns: var(--sidebar-width, 56px) auto 1fr auto; } .header-title { padding: 0 10px; @@ -1134,7 +1136,28 @@ h2 { display: none; } .transport-center { - padding: 0 12px; + padding: 0 10px; + } + /* Tighter meta cluster — drop the trailing separator and shrink gaps */ + .transport-meta { + gap: 10px; + padding: 0 4px 0 8px; + justify-content: flex-end; + } + .transport-meta .meta-sep:last-child { + display: none; + } + /* Tighter toolbar so it fits beside the meta cluster */ + .header-toolbar { + gap: 2px; + } + .header-toolbar-sep { + margin: 0 2px; + } + /* Hide secondary header items at narrow widths to free room */ + .header-link, + #tour-restart-btn { + display: none; } .container { @@ -1142,6 +1165,23 @@ h2 { } } +/* Tablet/phone shoulder: the meta cluster still wants ~280px which collides + with the toolbar below 900px. Drop CPU + Mem cells (Uptime + Poll stay, + they're the most useful at-a-glance signals). */ +@media (max-width: 900px) { + #transport-cpu, + #transport-mem { + display: none; + } + .transport-meta .meta-cell:has(#transport-cpu), + .transport-meta .meta-cell:has(#transport-mem) { + display: none; + } + .transport-meta > .meta-sep:nth-of-type(1) { + display: none; + } +} + @media (max-width: 600px) { header { grid-template-columns: auto 1fr auto; @@ -1154,6 +1194,11 @@ h2 { .transport-center { display: none; } + /* Below the phone breakpoint the sidebar vanishes and the bottom tab + bar takes over, so most of the meta cluster goes too. */ + .transport-meta { + display: none; + } .container { padding: 10px; } diff --git a/server/src/ledgrab/static/css/sidebar.css b/server/src/ledgrab/static/css/sidebar.css index 3687088..18497d6 100644 --- a/server/src/ledgrab/static/css/sidebar.css +++ b/server/src/ledgrab/static/css/sidebar.css @@ -243,19 +243,28 @@ .sidebar .tab-btn { grid-template-columns: 1fr; - padding: 10px 0; + padding: 10px 2px; justify-content: center; justify-items: center; - gap: 2px; + gap: 3px; } + /* Two-line caption with tight tracking — single-line ellipsis truncates + longer labels like "Automations"/"Integrations" to "AUTOMA…" which + isn't recoverable; two short lines are uglier per word but legible. */ .sidebar .tab-btn > span[data-i18n] { - font-size: 0.55rem; - letter-spacing: 0.05em; + font-size: 0.46rem; + letter-spacing: 0.02em; + line-height: 1.1; text-transform: uppercase; color: inherit; max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; overflow: hidden; - text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } .sidebar .tab-btn .icon { width: 20px; From 3f80ef2101431b15c0d51a4519258b1be31db699 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 15:10:48 +0300 Subject: [PATCH 08/11] feat: server shutdown action with public cancel_task lifecycle method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users choose what happens to LED targets when the server shuts down. Default ("stop_targets") runs the existing per-device stop sequence, so devices with auto-restore replay their prior state. "Nothing" cancels the capture tasks without sending restore frames, so the LEDs keep displaying their last frame on shutdown. Backend: - New setting ``shutdown_action`` persisted in db.settings (``stop_targets`` default | ``nothing``) with GET/PUT ``/api/v1/system/shutdown-action`` endpoints - ``ProcessorManager.stop_all(restore_devices: bool = True)`` now picks the path based on the flag — ``proc.stop()`` for the normal branch, public ``proc.cancel_task()`` for the "nothing" branch. - ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels the loop task and *awaits* its termination so no half-written frame is in flight when the process exits. Replaces an earlier draft that reached into the private ``_task`` attribute via ``getattr``. - Lifespan in ``main.py`` reads the setting at shutdown and forwards the flag; falls back to ``stop_targets`` on any read error. - ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock captured at first import of ``api.routes.system``) so the WebUI can show the *server's* uptime instead of the browser session's. Browser launch: - ``__main__._open_browser`` now polls ``/health`` for up to 30 s instead of sleeping a flat 2 s, so the tab opens once the server actually accepts requests. Frontend: - New "Shutdown action" picker in Settings → General, rendered via IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts`` + ``circle`` path to ``icon-paths.ts``). - Transport-bar uptime ticker reads ``window.__serverUptime`` (typed in ``global.d.ts``); shows "—" until the first /health response lands so refresh doesn't briefly flash 00:00:00. After 99 h the format widens to "Dd HH:MM:SS". - New i18n keys for the action picker (label, hint, opt.stop / opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh. No data migration needed — the setting is additive and defaults to the existing behavior. --- server/src/ledgrab/__main__.py | 24 +++++- server/src/ledgrab/api/routes/system.py | 9 +++ .../src/ledgrab/api/routes/system_settings.py | 52 ++++++++++++ server/src/ledgrab/api/schemas/system.py | 30 +++++++ .../core/processing/processor_manager.py | 51 +++++++++--- .../core/processing/target_processor.py | 30 +++++++ server/src/ledgrab/main.py | 17 +++- server/src/ledgrab/static/js/app.ts | 3 + server/src/ledgrab/static/js/core/api.ts | 13 +++ .../src/ledgrab/static/js/core/icon-paths.ts | 1 + server/src/ledgrab/static/js/core/icons.ts | 2 + .../ledgrab/static/js/features/settings.ts | 81 ++++++++++++++++++- server/src/ledgrab/static/js/global.d.ts | 10 +++ server/src/ledgrab/static/locales/en.json | 8 ++ server/src/ledgrab/static/locales/ru.json | 8 ++ server/src/ledgrab/static/locales/zh.json | 8 ++ server/src/ledgrab/templates/index.html | 23 ++++-- .../ledgrab/templates/modals/settings.html | 13 +++ 18 files changed, 359 insertions(+), 24 deletions(-) diff --git a/server/src/ledgrab/__main__.py b/server/src/ledgrab/__main__.py index 7402001..c11d8ac 100644 --- a/server/src/ledgrab/__main__.py +++ b/server/src/ledgrab/__main__.py @@ -12,6 +12,8 @@ import threading import time import webbrowser from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen def _fix_embedded_tcl_paths() -> None: @@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None: loop.run_until_complete(server.serve()) -def _open_browser(port: int, delay: float = 2.0) -> None: - """Open the UI in the default browser after a short delay.""" - time.sleep(delay) +def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool: + """Poll /health until the server responds or *timeout* seconds elapse.""" + url = f"http://localhost:{port}/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only + if 200 <= resp.status < 500: + return True + except (URLError, ConnectionError, OSError, TimeoutError): + pass + time.sleep(interval) + return False + + +def _open_browser(port: int) -> None: + """Open the UI in the default browser once the server is ready.""" + if not _wait_for_server(port): + logger.warning("Server did not become ready in time; opening browser anyway") webbrowser.open(f"http://localhost:{port}") diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index 190ceac..b94876f 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -7,6 +7,7 @@ import asyncio import platform import subprocess import sys +import time from datetime import datetime, timezone from typing import Optional @@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None: _cpu_name: str | None = _get_cpu_name() +# Captured at first import of this module. Process-wide elapsed time is +# the closest the server has to "app start" without instrumenting main.py; +# the system module is imported during router setup, before the server +# accepts requests, so the drift is negligible. Used by /health to expose +# uptime_seconds for the transport-bar ticker. +_APP_START_MONOTONIC: float = time.monotonic() + router = APIRouter() @@ -122,6 +130,7 @@ async def health_check(request: Request): setup_required=setup_required, repo_url=REPO_URL, donate_url=DONATE_URL, + uptime_seconds=time.monotonic() - _APP_START_MONOTONIC, ) diff --git a/server/src/ledgrab/api/routes/system_settings.py b/server/src/ledgrab/api/routes/system_settings.py index fcfd55b..172a37d 100644 --- a/server/src/ledgrab/api/routes/system_settings.py +++ b/server/src/ledgrab/api/routes/system_settings.py @@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import ( LogLevelResponse, MQTTSettingsRequest, MQTTSettingsResponse, + ShutdownAction, + ShutdownActionRequest, + ShutdownActionResponse, ) from ledgrab.config import get_config from ledgrab.storage.database import Database @@ -150,6 +153,55 @@ async def update_external_url( return ExternalUrlResponse(external_url=url) +# --------------------------------------------------------------------------- +# Shutdown action setting +# --------------------------------------------------------------------------- + +_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing") +_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets" + + +def load_shutdown_action(db: Database | None = None) -> ShutdownAction: + """Load the configured shutdown action. Returns the default if unset or corrupt.""" + if db is None: + from ledgrab.api.dependencies import get_database + + db = get_database() + data = db.get_setting("shutdown_action") + if not data: + return _DEFAULT_SHUTDOWN_ACTION + value = data.get("action") + if value in _VALID_SHUTDOWN_ACTIONS: + return value # type: ignore[return-value] + return _DEFAULT_SHUTDOWN_ACTION + + +@router.get( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)): + """Get the configured server shutdown action.""" + return ShutdownActionResponse(action=load_shutdown_action(db)) + + +@router.put( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def update_shutdown_action( + _: AuthRequired, + body: ShutdownActionRequest, + db: Database = Depends(get_database), +): + """Set what happens to LED targets when the server shuts down.""" + db.set_setting("shutdown_action", {"action": body.action}) + logger.info("Shutdown action updated: %s", body.action) + return ShutdownActionResponse(action=body.action) + + # --------------------------------------------------------------------------- # Live log viewer WebSocket # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index 23502dc..7ad43cb 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -26,6 +26,10 @@ class HealthResponse(BaseModel): ) repo_url: str = Field(default="", description="Source code repository URL") donate_url: str = Field(default="", description="Donation page URL") + uptime_seconds: float = Field( + default=0.0, + description="Process uptime in seconds since the server started.", + ) class VersionResponse(BaseModel): @@ -200,6 +204,32 @@ class ExternalUrlRequest(BaseModel): external_url: str = Field(default="", description="External base URL. Empty string to clear.") +# ─── Shutdown action schemas ─────────────────────────────────── + + +ShutdownAction = Literal["stop_targets", "nothing"] + + +class ShutdownActionResponse(BaseModel): + """Current server shutdown action setting.""" + + action: ShutdownAction = Field( + description=( + "What happens to LED targets when the server shuts down. " + "`stop_targets` runs the normal stop sequence (per-device " + "auto_shutdown decides whether prior state is restored). " + "`nothing` skips device-touching teardown — lights freeze on " + "their last frame regardless of per-device auto_shutdown." + ), + ) + + +class ShutdownActionRequest(BaseModel): + """Update the server shutdown action setting.""" + + action: ShutdownAction = Field(description="New shutdown action.") + + # ─── Log level schemas ───────────────────────────────────────── diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 08ac370..a1bcde2 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -770,8 +770,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) # ===== LIFECYCLE ===== - async def stop_all(self): - """Stop processing and health monitoring for all targets and devices.""" + async def stop_all(self, restore_devices: bool = True): + """Stop processing and health monitoring for all targets and devices. + + When ``restore_devices`` is False, processor tasks are cancelled + directly instead of going through ``proc.stop()`` (which sends + per-device auto_shutdown restore frames), and the global + idle-state restore loop is skipped. Used by the "Nothing" + shutdown action so lights freeze on their last frame regardless + of per-device auto_shutdown. + """ await self._metrics_history.stop() await self.stop_health_monitoring() @@ -781,18 +789,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) if rs.restart_task and not rs.restart_task.done(): rs.restart_task.cancel() - # Stop all processors - for target_id, proc in list(self._processors.items()): - if proc.is_running: - try: - await proc.stop() - except Exception as e: - logger.error(f"Error stopping target {target_id}: {e}") + if restore_devices: + # Stop all processors (per-device auto_shutdown decides whether + # the prior device state is restored). + for target_id, proc in list(self._processors.items()): + if proc.is_running: + try: + await proc.stop() + except Exception as e: + logger.error(f"Error stopping target {target_id}: {e}") - # Restore idle state for devices that have auto-restore enabled - # (serial devices already dark from processor close; WLED restored by snapshot) - for device_id in self._devices: - await self._restore_device_idle_state(device_id) + # Restore idle state for devices that have auto-restore enabled + # (serial devices already dark from processor close; WLED restored by snapshot) + for device_id in self._devices: + await self._restore_device_idle_state(device_id) + else: + # "Nothing" mode: cancel processor capture tasks without sending + # restore frames so the LEDs keep displaying the last frame. + # ``cancel_task`` (defined on ``TargetProcessor``) awaits the + # cancellation so the loop's current iteration completes — no + # half-written frame on the wire when the process exits. + for target_id, proc in list(self._processors.items()): + try: + await proc.cancel_task() + except Exception as e: + logger.error(f"Error cancelling task for target {target_id}: {e}") + logger.info( + "Shutdown action 'nothing': skipped device restore for %d target(s)", + len(self._processors), + ) # Close any cached idle LED clients (WLED only; serial has no cached clients) for did in list(self._idle_clients): diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index ffe96ea..5fdb2cb 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -16,6 +16,10 @@ from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + if TYPE_CHECKING: from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.live_stream_manager import LiveStreamManager @@ -145,6 +149,32 @@ class TargetProcessor(ABC): """ ... + async def cancel_task(self) -> None: + """Cancel the processing task without restoring device state. + + Used by ``ProcessorManager.stop_all(restore_devices=False)`` at + server shutdown when the user has chosen "Nothing" — LEDs should + keep displaying their last frame, so we skip the per-device + ``stop()`` path that sends restore frames. We still flip + ``_is_running`` and await the cancellation so the loop's current + iteration completes (no half-written frame on the wire). + + Subclasses with extra non-device cleanup (e.g. live-stream + release) may override this; the default just stops the task. + """ + self._is_running = False + task = self._task + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + # Log but don't propagate — caller is shutting down. + logger.debug("Task raised during cancel_task", exc_info=True) + self._task = None + # ----- Settings ----- @abstractmethod diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index bced30b..12c8823 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -412,9 +412,22 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping OS notification listener: {e}") - # Stop all processing + # Stop all processing. + # The shutdown action setting controls whether per-device restore + # frames are sent: "stop_targets" (default) runs the normal stop + # sequence; "nothing" cancels capture tasks so the LEDs freeze on + # their last frame. try: - await processor_manager.stop_all() + from ledgrab.api.routes.system_settings import load_shutdown_action + + action = load_shutdown_action(db) + except Exception as e: + logger.error(f"Error reading shutdown action setting, defaulting to stop_targets: {e}") + action = "stop_targets" + + logger.info("Shutdown action: %s", action) + try: + await processor_manager.stop_all(restore_devices=action != "nothing") logger.info("Stopped all processors") except Exception as e: logger.error(f"Error stopping processors: {e}") diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 90315c6..0c4348f 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -219,6 +219,7 @@ import { connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, openLogOverlay, closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, setShutdownAction, saveExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.ts'; import { @@ -615,6 +616,8 @@ Object.assign(window, { closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, + setShutdownAction, saveExternalUrl, getBaseOrigin, diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 8c74ac4..5f596ca 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -305,6 +305,19 @@ export async function loadServerInfo() { if (data.repo_url) serverRepoUrl = data.repo_url; if (data.donate_url) serverDonateUrl = data.donate_url; + // Seed the transport-bar uptime ticker with the server's actual + // uptime. Survives page reloads and tracks the *server* process, + // not this browser session. The inline ticker reads this from + // ``window.__serverUptime`` and falls back to "—" if absent. + // ``recordedAtPerf`` uses ``performance.now()`` so wall-clock + // changes (NTP step, DST) don't make the counter jump. + if (typeof data.uptime_seconds === 'number') { + window.__serverUptime = { + uptimeSec: data.uptime_seconds, + recordedAtPerf: performance.now(), + }; + } + // Demo mode detection if (data.demo_mode && !demoMode) { demoMode = true; diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 7c14bac..62cef63 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -23,6 +23,7 @@ export const flaskConical = ''; export const play = ''; export const square = ''; +export const circle = ''; export const pause = ''; export const settings = ''; export const ruler = ''; diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index e054ea8..6236f5a 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -342,6 +342,8 @@ export const ICON_GITHUB = _svg(P.github); export const ICON_CHEVRON_UP = _svg(P.chevronUp); export const ICON_CHEVRON_DOWN = _svg(P.chevronDown); export const ICON_PLUS = _svg(P.plus); +export const ICON_SQUARE = _svg(P.square); +export const ICON_CIRCLE = _svg(P.circle); export const ICON_GIT_MERGE = _svg(P.gitMerge); export const ICON_COPY = _svg(P.copy); diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index 66e0378..443ddd0 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; -import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts'; +import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts'; import { IconSelect } from '../core/icon-select.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; @@ -260,6 +260,13 @@ const settingsModal = new Modal('settings-modal'); let _logLevelIconSelect: IconSelect | null = null; let _autoBackupIntervalIconSelect: IconSelect | null = null; +let _shutdownActionIconSelect: IconSelect | null = null; + +type ShutdownAction = 'stop_targets' | 'nothing'; +const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const; +function _isShutdownAction(v: string): v is ShutdownAction { + return (_SHUTDOWN_ACTIONS as readonly string[]).includes(v); +} /** Build interval items (hour-tiles) for auto-backup and update check pickers. * Labels match the existing native-