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"])