feat(dashboard): per-account customizable dashboard with slide-in panel
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.
Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.
Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.
Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.
Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
|
||||
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 = `
|
||||
<header class="dash-cust-header">
|
||||
<h2 id="dash-cust-title">${t('dashboard.customize.title')}</h2>
|
||||
<button class="dash-cust-close" type="button" aria-label="${t('aria.close')}" onclick="closeDashboardCustomize()">${ICON_X}</button>
|
||||
</header>
|
||||
<div class="dash-cust-body" id="dash-cust-body"></div>
|
||||
`;
|
||||
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 `<button type="button" class="dash-cust-chip${active ? ' is-active' : ''}" data-preset="${name}">
|
||||
${t('dashboard.customize.preset.' + name)}
|
||||
</button>`;
|
||||
}).join('');
|
||||
const modifiedHint = layout.presetActive
|
||||
? ''
|
||||
: `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`;
|
||||
return `<section class="dash-cust-section">
|
||||
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
|
||||
<div class="dash-cust-chips">${chips}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 => `<button type="button" class="dash-cust-seg-btn${layout.global.width === o.v ? ' is-active' : ''}" data-global-width="${o.v}">${t(o.k)}</button>`).join('');
|
||||
const animBtns = animOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.animations === o.v ? ' is-active' : ''}" data-global-anim="${o.v}">${t(o.k)}</button>`).join('');
|
||||
const modeBtns = modeOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.perfMode === o.v ? ' is-active' : ''}" data-global-perfmode="${o.v}">${t(o.k)}</button>`).join('');
|
||||
const windowBtns = windowOpts.map(w => `<button type="button" class="dash-cust-seg-btn${layout.global.perfWindow === w ? ' is-active' : ''}" data-global-perfwindow="${w}">${w >= 60 ? `${w / 60}m` : `${w}s`}</button>`).join('');
|
||||
return `<section class="dash-cust-section">
|
||||
<h3 class="dash-cust-h3">${t('dashboard.customize.global')}</h3>
|
||||
<div class="dash-cust-row">
|
||||
<label class="dash-cust-label">${t('dashboard.customize.width')}</label>
|
||||
<div class="dash-cust-seg">${widthBtns}</div>
|
||||
</div>
|
||||
<div class="dash-cust-row">
|
||||
<label class="dash-cust-label">${t('dashboard.customize.anim')}</label>
|
||||
<div class="dash-cust-seg">${animBtns}</div>
|
||||
</div>
|
||||
<div class="dash-cust-row">
|
||||
<label class="dash-cust-label">${t('dashboard.customize.perf_mode')}</label>
|
||||
<div class="dash-cust-seg">${modeBtns}</div>
|
||||
</div>
|
||||
<div class="dash-cust-row">
|
||||
<label class="dash-cust-label">${t('dashboard.customize.window')}</label>
|
||||
<div class="dash-cust-seg">${windowBtns}</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function _renderSections(layout: DashboardLayoutV1): string {
|
||||
const perfRow = (() => {
|
||||
const perf = layout.sections.find(s => s.key === 'perf');
|
||||
if (!perf) return '';
|
||||
return `<div class="dash-cust-row dash-cust-row-fixed" data-section-key="perf">
|
||||
<span class="dash-cust-row-label">
|
||||
<span class="dash-cust-pin" title="${t('dashboard.customize.fixed_top')}">${ICON_LOCK}</span>
|
||||
${t(SECTION_LABEL_KEYS.perf)}
|
||||
</span>
|
||||
${_eyeBtn(perf.visible, 'section', 'perf')}
|
||||
</div>`;
|
||||
})();
|
||||
|
||||
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 =>
|
||||
`<button type="button" class="dash-cust-density${s.density === b.v ? ' is-active' : ''}" data-section-density="${key}" data-density="${b.v}" title="${t('dashboard.customize.density.' + b.v)}">${b.lbl}</button>`
|
||||
).join('');
|
||||
return `<div class="dash-cust-row dash-cust-row-drag" draggable="true" data-section-key="${key}">
|
||||
<span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
|
||||
<span class="dash-cust-row-label">${t(SECTION_LABEL_KEYS[key] || key)}</span>
|
||||
<span class="dash-cust-density-group">${densityHtml}</span>
|
||||
<button type="button" class="dash-cust-arrow" data-move="up" data-section-key="${key}" aria-label="↑">↑</button>
|
||||
<button type="button" class="dash-cust-arrow" data-move="down" data-section-key="${key}" aria-label="↓">↓</button>
|
||||
${_collapseBtn(s.collapsedDefault, 'section', key)}
|
||||
${_eyeBtn(s.visible, 'section', key)}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<section class="dash-cust-section" data-section-list>
|
||||
<h3 class="dash-cust-h3">${t('dashboard.customize.sections')}</h3>
|
||||
${perfRow}
|
||||
<div class="dash-cust-list" id="dash-cust-section-list">${rows}</div>
|
||||
<p class="dash-cust-help">${t('dashboard.customize.drag_help')}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 = `<select class="dash-cust-mini-select" data-cell-mode="${c.key}" title="${t('dashboard.customize.perf_mode')}">${
|
||||
modeOpts.map(m => `<option value="${m}"${c.mode === m ? ' selected' : ''}>${t('dashboard.customize.mode.' + m)}</option>`).join('')
|
||||
}</select>`;
|
||||
const windowSel = `<select class="dash-cust-mini-select" data-cell-window="${c.key}" title="${t('dashboard.customize.window')}">${
|
||||
windowOpts.map(w => {
|
||||
const lbl = w === 'inherit' ? t('dashboard.customize.mode.inherit') : (w >= 60 ? `${w / 60}m` : `${w}s`);
|
||||
return `<option value="${w}"${c.window === w ? ' selected' : ''}>${lbl}</option>`;
|
||||
}).join('')
|
||||
}</select>`;
|
||||
const yScaleSel = `<select class="dash-cust-mini-select" data-cell-yscale="${c.key}" title="${t('dashboard.customize.scale')}">${
|
||||
yScaleOpts.map(y => `<option value="${y}"${c.yScale === y ? ' selected' : ''}>${t('dashboard.customize.yscale.' + y)}</option>`).join('')
|
||||
}</select>`;
|
||||
return `<div class="dash-cust-row dash-cust-row-drag dash-cust-cell-row" draggable="true" data-cell-key="${c.key}">
|
||||
<div class="dash-cust-cell-top">
|
||||
<span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
|
||||
<span class="dash-cust-row-label">${t(PERF_CELL_LABEL_KEYS[c.key] || c.key)}</span>
|
||||
<button type="button" class="dash-cust-arrow" data-cell-move="up" data-cell-key="${c.key}" aria-label="↑">↑</button>
|
||||
<button type="button" class="dash-cust-arrow" data-cell-move="down" data-cell-key="${c.key}" aria-label="↓">↓</button>
|
||||
${_eyeBtn(c.visible, 'cell', c.key)}
|
||||
</div>
|
||||
<div class="dash-cust-cell-opts">
|
||||
<span class="dash-cust-cell-opt">
|
||||
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.mode_short')}</span>
|
||||
${modeSel}
|
||||
</span>
|
||||
<span class="dash-cust-cell-opt">
|
||||
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.window_short')}</span>
|
||||
${windowSel}
|
||||
</span>
|
||||
<span class="dash-cust-cell-opt">
|
||||
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.scale_short')}</span>
|
||||
${yScaleSel}
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<section class="dash-cust-section" data-cell-list>
|
||||
<h3 class="dash-cust-h3">${t('dashboard.customize.perf_cells')}</h3>
|
||||
<div class="dash-cust-list" id="dash-cust-cell-list">${rows}</div>
|
||||
<p class="dash-cust-help">${t('dashboard.customize.cell_drag_help')}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function _renderActions(): string {
|
||||
return `<section class="dash-cust-section dash-cust-actions">
|
||||
<button type="button" class="btn btn-secondary" data-action="export">${ICON_DOWNLOAD} ${t('dashboard.customize.export')}</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="import">${t('dashboard.customize.import')}</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="reset">${ICON_REFRESH} ${t('dashboard.customize.reset')}</button>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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 `<button type="button" class="dash-cust-eye${visible ? ' is-on' : ''}" ${dataAttr}="${key}" aria-pressed="${visible}" title="${label}" aria-label="${label}">${visible ? ICON_EYE : ICON_EYE_OFF}</button>`;
|
||||
}
|
||||
|
||||
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 `<button type="button" class="dash-cust-arrow${collapsed ? ' is-active' : ''}" data-section-collapse-default="${key}" aria-pressed="${collapsed}" title="${label}" aria-label="${label}">▾</button>`;
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _bindHandlers(root: HTMLElement): void {
|
||||
// Presets
|
||||
root.querySelectorAll<HTMLElement>('[data-preset]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const name = btn.dataset.preset!;
|
||||
applyDashboardPreset(name);
|
||||
});
|
||||
});
|
||||
|
||||
// Global toggles
|
||||
root.querySelectorAll<HTMLElement>('[data-global-width]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { width: btn.dataset.globalWidth as Width }));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>('[data-global-anim]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { animations: btn.dataset.globalAnim as AnimationsLevel }));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLSelectElement>('[data-cell-mode]').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const key = sel.dataset.cellMode!;
|
||||
saveDashboardLayout(setPerfCellMode(getDashboardLayout(), key, sel.value as PerfMode));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLSelectElement>('[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<HTMLElement>('[data-global-perfwindow]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const w = parseInt(btn.dataset.globalPerfwindow || '120', 10) as SampleWindow;
|
||||
saveDashboardLayout(setGlobalPerfWindow(getDashboardLayout(), w));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLSelectElement>('[data-cell-yscale]').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const key = sel.dataset.cellYscale!;
|
||||
saveDashboardLayout(setPerfCellYScale(getDashboardLayout(), key, sel.value as YScale));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('[data-action="export"]');
|
||||
if (exportBtn) exportBtn.addEventListener('click', _doExport);
|
||||
const importBtn = root.querySelector<HTMLButtonElement>('[data-action="import"]');
|
||||
if (importBtn) importBtn.addEventListener('click', _doImport);
|
||||
const resetBtn = root.querySelector<HTMLButtonElement>('[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<HTMLElement>(listSelector);
|
||||
if (!list) return;
|
||||
let dragKey: string | null = null;
|
||||
|
||||
list.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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();
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, () => 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<typeof setTimeout> | 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<void> {
|
||||
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<void> {
|
||||
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<GlobalConfig>): 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<DashboardLayoutV1>;
|
||||
|
||||
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<string, boolean>;
|
||||
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;
|
||||
}
|
||||
@@ -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} <span data-i18n="dashboard.errors">${labelText}</span>`;
|
||||
}
|
||||
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<string, boolean> {
|
||||
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
|
||||
catch { return {}; }
|
||||
let userOverrides: Record<string, boolean> = {};
|
||||
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<string, boolean> = {};
|
||||
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)
|
||||
? `<span class="dashboard-section-count">${count}</span>`
|
||||
: '';
|
||||
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
|
||||
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
|
||||
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
||||
${label}
|
||||
<span class="dashboard-section-count">${count}</span>
|
||||
${countHtml}
|
||||
</span>
|
||||
${extraHtml}
|
||||
</div>`;
|
||||
@@ -649,6 +699,14 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Build each section's HTML into a map so we can render in
|
||||
// user-defined order (layout-driven). Sections with no content
|
||||
// (e.g. `automations` when there are zero automations) produce
|
||||
// null and are skipped, unless the user explicitly toggled
|
||||
// them to show via Customize (we don't yet plumb a "show
|
||||
// empty CTA" mode here; that's a v1.1 follow-up).
|
||||
const sectionFragments: Record<string, string> = {};
|
||||
|
||||
// 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<v
|
||||
const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join('');
|
||||
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
|
||||
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
|
||||
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
|
||||
${_sectionContent('integrations', intGrid)}
|
||||
</div>`;
|
||||
@@ -670,7 +728,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
|
||||
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
|
||||
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
|
||||
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
|
||||
${_sectionContent('automations', automationGrid)}
|
||||
</div>`;
|
||||
@@ -680,7 +738,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
if (scenePresets.length > 0) {
|
||||
const sceneSec = renderScenePresetsSection(scenePresets);
|
||||
if (sceneSec && typeof sceneSec === 'object') {
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
|
||||
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
|
||||
${_sectionContent('scenes', sceneSec.content)}
|
||||
</div>`;
|
||||
@@ -691,7 +749,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
if (syncClocks.length > 0) {
|
||||
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
||||
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
|
||||
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
|
||||
${_sectionContent('sync-clocks', clockGrid)}
|
||||
</div>`;
|
||||
@@ -720,33 +778,62 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
</div>`;
|
||||
}
|
||||
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
sectionFragments['targets'] = `<div class="dashboard-section" data-section="targets">
|
||||
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
|
||||
${_sectionContent('targets', targetsInner)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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 = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
||||
const perfVisible = isSectionVisible('perf');
|
||||
const customizeBtn = `<button class="tutorial-trigger-btn" onclick="openDashboardCustomize()" title="${t('dashboard.customize.title')}" aria-label="${t('dashboard.customize.title')}">${ICON_SETTINGS}</button>`;
|
||||
const tutorialBtn = `<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button>`;
|
||||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${customizeBtn}${tutorialBtn}</span></div>`;
|
||||
if (isFirstLoad) {
|
||||
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
|
||||
const perfBlock = perfVisible
|
||||
? `<div class="dashboard-perf-persistent dashboard-section" data-section="perf">
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
|
||||
${_sectionContent('perf', renderPerfSection())}
|
||||
</div>
|
||||
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||
await initPerfCharts();
|
||||
</div>`
|
||||
: '';
|
||||
container.innerHTML = `${toolbar}${perfBlock}<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||
_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
|
||||
<canvas class="mod-metric-spark-canvas" id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
|
||||
</div>
|
||||
<div class="mod-metric" title="${t('dashboard.uptime')}">
|
||||
<span class="k" data-i18n="dashboard.uptime">Uptime</span>
|
||||
<span class="k">${ICON_CLOCK} <span data-i18n="dashboard.uptime">Uptime</span></span>
|
||||
<span class="v" data-uptime-text="${target.id}">${uptime}</span>
|
||||
</div>
|
||||
<div class="mod-metric" title="${t('dashboard.errors')}">
|
||||
<span class="k" data-i18n="dashboard.errors">Errors</span>
|
||||
<span class="v" data-errors-text="${target.id}" title="${errors}">${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}</span>
|
||||
<div class="mod-metric" title="${t('dashboard.errors')}" data-errors-cell="${target.id}">
|
||||
<span class="k">${errors > 0 ? ICON_WARNING : ICON_OK} <span data-i18n="dashboard.errors">Errors</span></span>
|
||||
<span class="v${errors > 0 ? ' has-errors' : ''}" data-errors-text="${target.id}" title="${errors}">${formatCompact(errors)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mod-foot">
|
||||
@@ -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<typeof setTimeout> | 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) {
|
||||
|
||||
@@ -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) => `
|
||||
<div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}"${hidden ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
|
||||
const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => `
|
||||
<div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
|
||||
<div class="perf-chart-header">
|
||||
<span class="perf-chart-label">${t(labelKey)} ${createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true })}</span>
|
||||
<span class="perf-chart-app" id="perf-${key}-app" aria-hidden="true"></span>
|
||||
@@ -152,9 +176,6 @@ export function renderPerfSection(): string {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 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 = `
|
||||
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}">
|
||||
<div class="perf-chart-header">
|
||||
@@ -169,8 +190,6 @@ export function renderPerfSection(): string {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Devices cell — N online / M configured + colored dot strip per device.
|
||||
// No sparkline; the list of dots serves as its visual indicator.
|
||||
const devicesCell = `
|
||||
<div class="perf-chart-card perf-devices-cell" data-metric="devices">
|
||||
<div class="perf-chart-header">
|
||||
@@ -187,15 +206,28 @@ export function renderPerfSection(): string {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `<div class="perf-charts-grid">
|
||||
${patchesCell}
|
||||
${fpsCell}
|
||||
${devicesCell}
|
||||
${card('cpu', 'dashboard.perf.cpu')}
|
||||
${card('ram', 'dashboard.perf.ram')}
|
||||
${card('gpu', 'dashboard.perf.gpu')}
|
||||
${card('temp', 'dashboard.perf.temp', true)}
|
||||
</div>`;
|
||||
// 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, () => 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 `<div class="perf-charts-grid">${cellsHtml}</div>`;
|
||||
}
|
||||
|
||||
/** 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 <path> 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Добавить автоматизацию",
|
||||
|
||||
@@ -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": "添加自动化",
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user