feat(ui): dashboard polish, richer perf strip, transport-bar controls

Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
  replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
  widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
  to 4 live FPS readouts with channel-colored stripes; bottom-right
  radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
  "fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
  online with signal-glow, coral when offline, tooltip with name +
  latency), fed from /devices/batch/states (added to the dashboard
  batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
  long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
  instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
  align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
  card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
  positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
  drop-shadow filter for the "lit instrument" feel. Chart.js still used
  for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
  not percent — convert to percent using sample's ram_total before
  pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
  history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
  reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
  instead of silently hiding; .perf-chart-card-hint neutralizes the big
  display font so the message reads as plain body copy.

Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
  brandPulse animation. Brand-stack wraps the title + version so
  "LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
  inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
  CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
  readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
  replaces the range slider that used to live in the Dashboard toolbar
  (it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
  glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
  across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
  coral when offline via :has() selector on .header-title.

Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
  name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
  and patch-label + stop button. Running cards get the signal-flow
  strip at the bottom. data-fps-text / data-uptime-text / data-errors-
  text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
  144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
  layout with online/offline patch indicator. Integration card stripe
  uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
  treatment. Automations/scenes dropped into a dashboard-autostart-grid
  so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
  tab-badge / dashboard-section-count badges all use the mono
  rectangular style with tabular-nums.

Command palette:
- Flat background (no gradient), channel-accent rule across the top,
  mono placeholder / group headers / footer, active result gets a
  channel-green left stripe.

Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
  automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
  override.
- Modal header picks up a vertical channel stripe + hairline divider;
  footer gets hairline top + subtle wash.

Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
  focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
  coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
  --lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.

Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
  inside an overflow: hidden / auto / clip ancestor (perf strip, modal
  bodies, tree-dd panels). Prevents the popover getting clipped.

Settings modal:
- Remembers the last-opened tab via localStorage key
  settings_active_tab; falls back to 'general' if the tab id no longer
  exists. Explicit overrides (donation → about, update badge →
  updates) still work because callers invoke switchSettingsTab after
  openSettingsModal.

Microcopy:
- Sidebar / transport localization for en/ru/zh:
  sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
  · transport.status.{ready,armed} · dashboard.perf.{active_patches,
  total_fps,devices}

Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
  temperature is available, so the Temperature card can render an
  actionable explainer instead of being hidden. Frontend respects the
  key via t() lookup.

Section headers:
- Underline switched from dashed to solid; channel-green accent rule
  (40 px) on the left remains.

Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
This commit is contained in:
2026-04-24 20:28:44 +03:00
parent 539e43195f
commit e5a2af9821
22 changed files with 2125 additions and 711 deletions
+10
View File
@@ -316,6 +316,15 @@ def get_system_performance(_: AuthRequired):
except Exception as e:
logger.debug("NVML query failed: %s", e)
# Windows has no user-space CPU die temperature source without a kernel
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
# WMI sensors when the user runs them. When no reading arrives, surface
# that explicitly so the dashboard can show a "here's how to enable it"
# hint instead of silently hiding the card.
cpu_temp_hint_key: str | None = None
if thermals.cpu_temp_c is None and platform.system() == "Windows":
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
return PerformanceResponse(
cpu_name=_cpu_name,
cpu_percent=metrics.cpu_percent(),
@@ -328,6 +337,7 @@ def get_system_performance(_: AuthRequired):
battery_percent=thermals.battery_percent,
battery_temp_c=thermals.battery_temp_c,
cpu_temp_c=thermals.cpu_temp_c,
cpu_temp_hint_key=cpu_temp_hint_key,
timestamp=datetime.now(timezone.utc),
)
+9
View File
@@ -98,6 +98,15 @@ class PerformanceResponse(BaseModel):
default=None,
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)",
)
cpu_temp_hint_key: str | None = Field(
default=None,
description=(
"i18n key for an explainer shown in the Temperature card when "
"cpu_temp_c is null and the platform has a known workaround "
"(e.g. install LibreHardwareMonitor on Windows). Null on "
"platforms where unavailable simply means 'not reported'."
),
)
timestamp: datetime = Field(description="Measurement timestamp")
+26 -21
View File
@@ -99,9 +99,9 @@
/* Dark theme (default) */
[data-theme="dark"] {
--bg-color: #1a1a1a;
--bg-secondary: #242424;
--card-bg: #2d2d2d;
--bg-color: #000000;
--bg-secondary: #0a0b0d;
--card-bg: #101216;
--text-color: #e0e0e0;
--text-primary: #e0e0e0;
--text-secondary: #999;
@@ -115,9 +115,9 @@
--input-bg: #1a1a2e;
color-scheme: dark;
/* ── Lumenworks dark palette ── */
--lux-bg-0: #0a0b0d;
--lux-bg-1: #101216;
/* ── Lumenworks dark palette — page is pure black, cards elevate ── */
--lux-bg-0: #000000;
--lux-bg-1: #0e1014;
--lux-bg-2: #15181d;
--lux-bg-3: #1c2027;
--lux-line: #232831;
@@ -127,9 +127,13 @@
--lux-ink-mute: #5b6473;
--lux-ink-faint:#3a414c;
/* Channel palette — consistent across tabs for entity types */
--ch-signal: #00ff7a; /* capture / targets — primary */
--ch-signal-dim: #00b85a;
/* Channel palette — consistent across tabs for entity types.
--ch-signal tracks --primary-color so the accent color picker
propagates through the brand mark, running stripes, transport
chip, active tabs, etc. Other channels are fixed hues used for
non-primary entity types. */
--ch-signal: var(--primary-color);
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
--ch-cyan: #00d8ff; /* data / sources / screen */
--ch-magenta: #ff4ade; /* audio / FFT */
--ch-amber: #ffb800; /* autostart / pending */
@@ -142,9 +146,9 @@
/* Light theme */
[data-theme="light"] {
--bg-color: #f5f5f5;
--bg-secondary: #eee;
--card-bg: #ffffff;
--bg-color: #ffffff;
--bg-secondary: #fafbfc;
--card-bg: #f5f6f8;
--text-color: #333333;
--text-primary: #333333;
--text-secondary: #595959;
@@ -163,13 +167,13 @@
--primary-text: #2e7d32;
color-scheme: light;
/* ── Lumenworks light palette — tuned for WCAG AA on white.
Channel colors darkened vs dark theme so they read against
near-white surfaces. ── */
--lux-bg-0: #f5f6f8;
--lux-bg-1: #ffffff;
--lux-bg-2: #fafbfc;
--lux-bg-3: #eef1f5;
/* ── Lumenworks light palette — page is pure white, cards slightly
off-white so the stripe + hairline border still read against
the page. WCAG AA tuned. ── */
--lux-bg-0: #ffffff;
--lux-bg-1: #f6f8fb;
--lux-bg-2: #eef1f5;
--lux-bg-3: #e4e8ee;
--lux-line: #dee3ea;
--lux-line-bold:#c4ccd6;
--lux-ink: #0f1419;
@@ -177,8 +181,9 @@
--lux-ink-mute: #6b7684;
--lux-ink-faint:#a5afbc;
--ch-signal: #008f3f;
--ch-signal-dim: #006b2f;
/* --ch-signal tracks --primary-color so the accent picker propagates. */
--ch-signal: var(--primary-color);
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
--ch-cyan: #006b88;
--ch-magenta: #b01a99;
--ch-amber: #a56a00;
+2 -4
View File
@@ -19,6 +19,7 @@ section {
min-height: 140px;
position: relative;
overflow: hidden;
/* keep solid — same flat black/white language as real cards */
}
/* Small corner bracket + left hairline so the skeleton reads as a module
@@ -135,9 +136,7 @@ section {
.card {
--ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
@@ -203,7 +202,6 @@ section {
.card[data-scene-id],
.card.ch-violet { --ch: var(--ch-violet, #8b7eff); }
.card[data-card-type="integration"],
.card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); }
.card[data-card-type="offline"],
File diff suppressed because it is too large Load Diff
+255 -96
View File
@@ -4,7 +4,7 @@
header {
display: grid;
grid-template-columns: var(--sidebar-width, 248px) 1fr auto;
grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto;
align-items: center;
height: var(--transport-height, 60px);
padding: 0 16px 0 0;
@@ -49,58 +49,96 @@ header::before {
/* Glowing LED brand mark. Rendered as a ::before on .header-title so no
HTML change is required. The existing #server-status pulse dot sits
inside as the "core" of the mark (see status-badge rule below). */
/* LED brand mark — 28 px glowing square with inset dark core.
Glow intensity pulses subtly to reinforce the "live instrument" feel. */
.header-title::before {
content: '';
width: 26px;
height: 26px;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
background: var(--ch-signal, var(--primary-color));
box-shadow:
var(--lux-signal-glow, 0 0 14px color-mix(in srgb, var(--primary-color) 40%, transparent)),
inset 0 0 0 1px rgba(0, 0, 0, 0.3);
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
position: relative;
animation: brandPulse 4s ease-in-out infinite;
}
.header-title::after {
content: '';
position: absolute;
left: calc(18px + 7px);
left: calc(18px + 8px);
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background: var(--lux-bg-0, var(--bg-color));
border-radius: 1px;
border-radius: 2px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
pointer-events: none;
}
@keyframes brandPulse {
0%, 100% {
box-shadow:
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
50% {
box-shadow:
0 0 30px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 70%, transparent),
0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 95%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
}
/* Brand stack — title on one line, version under it, no wrap. */
.brand-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
line-height: 1;
min-width: 0;
}
h1 {
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.2em;
font-size: 1.25rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
-webkit-text-stroke: 0.5px var(--primary-color);
white-space: nowrap;
-webkit-text-stroke: 0.4px color-mix(in srgb, var(--primary-color) 60%, transparent);
paint-order: stroke fill;
background: linear-gradient(
120deg,
var(--primary-color) 0%,
var(--primary-text-color) 35%,
var(--primary-color) 50%,
var(--primary-text-color) 65%,
var(--primary-color) 100%
90deg,
var(--lux-ink, #e6ebf2) 0%,
var(--ch-signal, var(--primary-color)) 50%,
var(--lux-ink, #e6ebf2) 100%
);
background-size: 250% 100%;
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: titleShimmer 6s ease-in-out infinite;
animation: titleShimmer 8s linear infinite;
line-height: 1;
margin: 0;
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent));
}
.brand-stack #server-version {
font-size: 0.6rem;
padding: 2px 7px;
letter-spacing: 0.25em;
align-self: flex-start;
}
@keyframes titleShimmer {
0%, 100% { background-position: 100% 50%; }
50% { background-position: 0% 50%; }
to { background-position: -220% 50%; }
}
/* ── Transport center: reserved area for armed-status / master-stop /
@@ -120,31 +158,38 @@ h1 {
.transport-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
gap: 10px;
padding: 9px 18px;
background: var(--lux-bg-2, var(--bg-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
color: var(--lux-ink-dim, var(--text-secondary));
font-size: 0.68rem;
letter-spacing: 0.08em;
font-family: var(--font-mono, inherit);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s, background 0.2s;
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.transport-status.is-armed {
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 10%, transparent);
box-shadow:
inset 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent),
0 0 18px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
}
.transport-status .dot {
width: 6px; height: 6px;
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 6px currentColor;
box-shadow: 0 0 8px currentColor, 0 0 3px currentColor;
animation: pulse 1.4s ease-in-out infinite;
flex-shrink: 0;
}
.transport-status:not(.is-armed) .dot {
background: var(--lux-ink-faint, var(--text-muted));
@@ -152,6 +197,77 @@ h1 {
animation: none;
}
/* Transport meta — Uptime / CPU / Mem readouts as vertical KEY/VALUE stacks */
.transport-meta {
display: flex;
align-items: center;
gap: 16px;
padding: 0 6px 0 16px;
font-family: var(--font-mono, monospace);
}
.meta-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
line-height: 1;
min-width: 0;
}
.meta-cell .k {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--lux-ink-faint, var(--text-muted));
}
.meta-cell .v {
font-size: 0.9rem;
font-weight: 600;
color: var(--lux-ink, var(--text-color));
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* Interactive meta-cell — clickable variant used by the Poll control.
Lightweight hover + focus states so it reads as actionable without
looking like a button. */
.meta-cell-interactive {
cursor: pointer;
padding: 4px 8px;
margin: 0 -2px;
border-radius: var(--lux-r-sm, 3px);
border: var(--lux-hairline, 1px) solid transparent;
outline: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
user-select: none;
}
.meta-cell-interactive:hover {
background: var(--lux-bg-2, var(--bg-secondary));
border-color: var(--lux-line, var(--border-color));
}
.meta-cell-interactive:focus-visible {
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
}
.meta-cell-interactive:active {
transform: translateY(0.5px);
}
.meta-cell-interactive .v {
color: var(--ch-signal, var(--primary-color));
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent);
}
.meta-sep {
width: 1px;
height: 24px;
background: var(--lux-line, var(--border-color));
flex-shrink: 0;
}
h2 {
margin-bottom: 20px;
color: var(--text-color);
@@ -161,18 +277,17 @@ h2 {
.header-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
padding: 3px 4px;
gap: 4px;
background: transparent;
border: none;
padding: 0;
}
.header-toolbar-sep {
width: 1px;
height: 16px;
height: 20px;
background: var(--lux-line, var(--border-color));
margin: 0 3px;
margin: 0 6px;
flex-shrink: 0;
}
@@ -380,35 +495,27 @@ h2 {
to { transform: translateY(0); opacity: 1; }
}
/* #server-status is overlaid on the brand mark as a small LED pip that
changes color based on connection. Inline element rendered via text node
('●'); we hide the glyph and use ::before for a clean circular dot so
sizing is consistent across fonts. */
/* #server-status visual hidden — the brand mark itself carries the
connection state. When JS adds `.offline`, the mark shifts to coral
via the :has() modifier on .header-title below. */
.status-badge {
position: absolute;
left: calc(18px + 13px);
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
color: transparent; /* hide the '●' text glyph */
font-size: 0;
background: var(--lux-bg-0, var(--bg-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 6px var(--ch-signal, var(--primary-color));
z-index: 2;
animation: pulse 2s infinite;
pointer-events: none;
display: none;
}
.status-badge.online {
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-signal, var(--primary-color));
}
.status-badge.offline {
/* Brand mark reflects connection state. Default is the running-color
(tracks --ch-signal / --primary-color). When the server-status element
has `.offline`, override to coral so the header reads "disconnected"
without needing a separate pip. */
.header-title:has(#server-status.offline)::before {
background: var(--ch-coral, var(--danger-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-coral, var(--danger-color));
box-shadow:
0 0 22px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 55%, transparent),
0 0 8px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 90%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
animation: none;
}
.header-title:has(#server-status.offline)::after {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 40%, transparent);
}
@keyframes pulse {
@@ -656,23 +763,35 @@ h2 {
}
/* Header toolbar buttons */
/* Header icon buttons — hairline-bordered squares with channel glow
on hover. Mirrors the mockup's `.icon-btn` treatment. */
.header-btn {
width: 30px;
height: 30px;
padding: 0;
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 5px;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
transition: color 0.2s, background 0.2s;
display: inline-flex;
align-items: center;
color: var(--lux-ink-dim, var(--text-secondary));
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
display: inline-grid;
place-items: center;
line-height: 1;
flex-shrink: 0;
}
.header-btn:hover {
color: var(--text-color);
background: var(--bg-secondary);
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-2, var(--bg-secondary));
border-color: var(--lux-line-bold, var(--border-color));
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
}
.header-btn .icon {
width: 15px;
height: 15px;
}
/* Reusable color picker popover */
@@ -814,8 +933,11 @@ h2 {
.cp-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
background: radial-gradient(1000px 600px at 50% 30%,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.8) 100%);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fadeIn 0.15s ease-out;
}
@@ -824,10 +946,13 @@ h2 {
width: 520px;
max-width: 90vw;
max-height: 60vh;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 16px 48px var(--shadow-color);
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-md, 12px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 20px 60px rgba(0, 0, 0, 0.5),
0 8px 32px var(--shadow-color);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -835,6 +960,24 @@ h2 {
animation: cpSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Channel-accent rule across the top edge (matches modals) */
.cp-dialog::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--ch-signal, var(--primary-color)) 20%,
var(--ch-cyan, var(--primary-color)) 50%,
var(--ch-magenta, var(--primary-color)) 80%,
transparent 100%);
opacity: 0.9;
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
pointer-events: none;
z-index: 2;
}
@keyframes cpSlideDown {
from { opacity: 0; transform: translateY(-12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
@@ -842,18 +985,23 @@ h2 {
.cp-input {
width: 100%;
padding: 14px 16px;
padding: 16px 18px 14px 18px;
border: none;
border-bottom: 1px solid var(--border-color);
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
background: transparent;
color: var(--text-color);
color: var(--lux-ink, var(--text-color));
font-family: var(--font-body, inherit);
font-size: 1rem;
outline: none;
box-sizing: border-box;
letter-spacing: -0.005em;
}
.cp-input::placeholder {
color: var(--text-secondary);
color: var(--lux-ink-mute, var(--text-secondary));
font-family: var(--font-mono, inherit);
font-size: 0.9rem;
letter-spacing: 0.04em;
}
.cp-results {
@@ -863,32 +1011,38 @@ h2 {
}
.cp-group-header {
font-size: 0.7rem;
font-family: var(--font-mono, inherit);
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
padding: 8px 16px 4px;
letter-spacing: 0.22em;
color: var(--lux-ink-mute, var(--text-secondary));
padding: 10px 18px 4px;
}
.cp-result {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
gap: 10px;
padding: 9px 18px;
cursor: pointer;
transition: background 0.1s;
position: relative;
z-index: 1;
color: var(--lux-ink-dim, var(--text-color));
}
.cp-result:hover {
background: var(--bg-secondary);
background: var(--lux-bg-3, var(--bg-secondary));
color: var(--lux-ink, var(--text-color));
}
.cp-result.cp-active {
background: var(--primary-color);
color: var(--primary-contrast);
background: linear-gradient(90deg,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent) 0%,
transparent 100%);
color: var(--lux-ink, var(--text-color));
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
}
.cp-result.cp-active .cp-detail {
@@ -914,8 +1068,10 @@ h2 {
.cp-detail {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--text-secondary);
font-family: var(--font-mono, inherit);
font-size: 0.66rem;
letter-spacing: 0.04em;
color: var(--lux-ink-mute, var(--text-secondary));
}
.cp-running {
@@ -950,11 +1106,14 @@ h2 {
}
.cp-footer {
padding: 6px 16px;
border-top: 1px solid var(--border-color);
font-size: 0.7rem;
color: var(--text-secondary);
padding: 8px 18px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
font-family: var(--font-mono, inherit);
font-size: 0.62rem;
letter-spacing: 0.08em;
color: var(--lux-ink-mute, var(--text-secondary));
text-align: center;
background: color-mix(in srgb, var(--lux-bg-0, transparent) 40%, transparent);
}
/* On narrow screens the brand column shrinks to just the mark; on phones
+6 -6
View File
@@ -81,7 +81,7 @@
border: none;
border-radius: var(--lux-r-sm, 3px);
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
font-size: 0.82rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--lux-ink-dim, var(--text-secondary));
@@ -139,12 +139,12 @@
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-mute, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
font-size: 0.62rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
min-width: 18px;
line-height: 1.3;
padding: 1px 7px;
border-radius: 10px;
min-width: 20px;
line-height: 1.4;
text-align: center;
}
.sidebar .tab-btn.active .tab-badge {
+6 -6
View File
@@ -694,14 +694,14 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.subtab-section-header {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
font-weight: 600;
font-size: 0.82rem;
font-weight: 700;
color: var(--lux-ink-dim, var(--text-secondary));
margin: 0 0 14px 0;
padding-bottom: 8px;
margin: 0 0 16px 0;
padding-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
border-bottom: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color));
letter-spacing: 0.25em;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
position: relative;
}
+4
View File
@@ -797,6 +797,10 @@ document.addEventListener('DOMContentLoaded', async () => {
startEventsWS();
startEntityEventListeners();
startAutoRefresh();
// Perf poll starts globally so the transport-bar CPU / Mem cells stay
// live regardless of which tab is active. Tab-hidden pauses it via the
// visibilitychange handler in perf-charts.ts.
startPerfPolling();
// Initialize update checker (banner + WS listener)
initUpdateListener();
@@ -3,11 +3,14 @@
*
* Both dashboard.js and targets.js need nearly identical Chart.js line charts
* for FPS visualization. This module provides a single factory so the config
* lives in one place.
*
* Requires Chart.js to be registered globally (done by perf-charts.js).
* lives in one place and owns the global Chart.js registration.
*/
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
// Expose globally for legacy code paths that still reference window.Chart.
window.Chart = Chart;
const DEFAULT_MAX_SAMPLES = 120;
/** Left-pad an array with nulls so it always has `maxSamples` entries. */
@@ -28,7 +31,7 @@ function _padLeft(arr: number[], maxSamples: number): (number | null)[] {
* @returns {Chart|null}
*/
export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) {
const canvas = document.getElementById(canvasId);
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!canvas) return null;
const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES;
@@ -69,6 +69,25 @@ function _rgbToHex(rgb: string) {
return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
}
/** True if any ancestor between `el` and <body> has overflow:hidden / clip
* / auto on x or y. Used by the picker toggle to decide whether it must
* detach the popover to <body> with fixed positioning so it isn't
* clipped. */
function _hasOverflowClipAncestor(el: Element): boolean {
let cur: Element | null = el.parentElement;
while (cur && cur !== document.body) {
const cs = getComputedStyle(cur);
const ox = cs.overflowX;
const oy = cs.overflowY;
if (ox === 'hidden' || ox === 'clip' || ox === 'auto' ||
oy === 'hidden' || oy === 'clip' || oy === 'auto') {
return true;
}
cur = cur.parentElement;
}
return false;
}
window._cpToggle = function (id) {
// Close all other pickers first (and drop their card elevation)
document.querySelectorAll('.color-picker-popover').forEach((p: Element) => {
@@ -108,6 +127,32 @@ window._cpToggle = function (id) {
pop.style.animation = 'none';
pop.style.zIndex = '10000';
pop.classList.add('cp-fixed');
} else {
// Desktop: detach to body with fixed positioning when the swatch sits
// inside an overflow:hidden ancestor (e.g. the perf-chart strip,
// modal body, tree-dd panel). Otherwise the popover is clipped.
const swatchEl = document.getElementById(`cp-swatch-${id}`);
const hasClippingAncestor = swatchEl && _hasOverflowClipAncestor(swatchEl);
if (hasClippingAncestor && pop.parentElement !== document.body) {
(pop as any)._cpOrigParent = pop.parentElement;
(pop as any)._cpOrigNext = pop.nextSibling;
document.body.appendChild(pop);
const swRect = swatchEl!.getBoundingClientRect();
pop.style.position = 'fixed';
pop.style.top = `${swRect.bottom + 8}px`;
// Anchor on the left edge of the swatch, but clamp so the
// popover doesn't run off the right edge of the viewport.
const popWidth = 240; // approx; refined after first paint
let left = swRect.left;
if (left + popWidth > window.innerWidth - 12) {
left = Math.max(12, window.innerWidth - popWidth - 12);
}
pop.style.left = `${left}px`;
pop.style.right = 'auto';
pop.style.margin = '0';
pop.style.zIndex = '10000';
pop.classList.add('cp-fixed');
}
}
// Mark active dot
+223 -103
View File
@@ -6,7 +6,7 @@ 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 } from './perf-charts.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import {
@@ -265,12 +265,23 @@ function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
if (speedEl) speedEl.textContent = `${c.speed}×`;
card.classList.toggle('is-running', c.is_running);
const led = card.querySelector('.mod-leds .led');
if (led) led.className = c.is_running ? 'led on blink' : 'led';
const patch = card.querySelector('.mod-patch');
if (patch) {
const dot = patch.querySelector('.patch-dot');
if (dot) dot.className = c.is_running ? 'patch-dot is-live' : 'patch-dot';
const label = patch.querySelector('span:last-child');
if (label) label.textContent = c.is_running ? 'TICKING' : 'PAUSED';
}
const btn = card.querySelector('.mod-foot .mod-btn');
if (btn) {
btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`;
btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`);
btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START;
btn.className = c.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
btn.setAttribute('onclick', `event.stopPropagation(); ${c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`}`);
const label = c.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
btn.innerHTML = `${c.is_running ? ICON_PAUSE : ICON_START} <span>${label}</span>`;
}
}
}
@@ -284,14 +295,24 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
const patchLive = conn.connected ? ' is-live' : '';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_HOME}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">HA · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${escapeHtml(subtitle)}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
@@ -301,20 +322,44 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected');
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'MQ';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
const patchLive = conn.connected ? ' is-live' : '';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_RADIO}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">MQTT · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${subtitle}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void {
// Update health dots and subtitles for each integration card
const applyState = (card: Element, connected: boolean, patchLabel: string): void => {
card.classList.toggle('is-running', connected);
const led = card.querySelector('.mod-leds .led');
if (led) {
led.className = connected ? 'led on blink' : 'led';
}
const patch = card.querySelector('.mod-patch');
if (patch) {
const dot = patch.querySelector('.patch-dot');
if (dot) dot.className = connected ? 'patch-dot is-live' : 'patch-dot';
const label = patch.querySelector('span:last-child');
if (label) label.textContent = patchLabel;
}
};
for (const conn of haStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
if (!card) continue;
@@ -325,14 +370,14 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
? `${t('ha_source.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
// Update MQTT integration cards
if (mqttStatus) {
for (const conn of mqttStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
@@ -342,10 +387,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
}
// Update section count badge
@@ -363,41 +409,41 @@ function renderDashboardSyncClock(clock: SyncClock): string {
? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const subtitle = [
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
const metaParts = [
`<span class="dashboard-clock-speed">${clock.speed}×</span>`,
clock.description ? escapeHtml(clock.description) : '',
].filter(Boolean).join(' · ');
].filter(Boolean);
const short = (clock.id || '').replace(/^sc_/, '').slice(0, 2).toUpperCase() || 'CK';
const ledCls = clock.is_running ? 'led on blink' : 'led';
const patchLabel = clock.is_running ? 'TICKING' : 'PAUSED';
const patchLive = clock.is_running ? ' is-live' : '';
const btnCls = clock.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
const scStyle = cardColorStyle(clock.id);
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(clock.name)}</div>
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</div>` : ''}
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${clock.is_running ? 'is-running' : ''}" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}">
${clock.is_running ? ICON_PAUSE : ICON_START}
</button>
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
${ICON_CLOCK}
</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
<button class="mod-btn" onclick="event.stopPropagation(); dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
</div>
</div>`;
}
function _renderPollIntervalSelect(): string {
const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => {
const ms = parseInt(String(value), 10) * 1000;
@@ -482,7 +528,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp] = await Promise.all([
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []),
@@ -493,8 +539,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/status').catch(() => null),
fetchWithAuth('/mqtt/status').catch(() => null),
fetchWithAuth('/devices/batch/states').catch(() => null),
]);
// Devices cell — online/offline count + dot strip. Independent of
// the running-target set: shows every configured device regardless
// of whether any target is currently streaming to it.
if (deviceStatesResp && deviceStatesResp.ok) {
try {
const payload = await deviceStatesResp.json();
const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[];
updateDevices(deviceStateList);
} catch { /* ignore parse errors */ }
}
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const automations = automationsData.automations || [];
const devicesMap = {};
@@ -529,6 +588,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const stopped = enriched.filter(t => !t.state || !t.state.processing);
updateTabBadge('targets', running.length);
_updateTransportStatus(running.length);
updateActivePatches(
running.map(r => ({
id: r.id,
name: r.name,
fps: r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: undefined,
})),
enriched.length,
);
// Aggregate throughput across all running targets — fills the
// Total FPS cell in the perf strip.
const fpsValues: number[] = [];
let fpsSum = 0;
for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: null;
if (fps != null) {
fpsValues.push(fps);
fpsSum += fps;
}
}
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
updateTotalFps(fpsSum, fpsMin, fpsMax);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
@@ -577,10 +662,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
updateTabBadge('automations', activeAutomations.length);
const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionContent('automations', automationItems)}
${_sectionContent('automations', automationGrid)}
</div>`;
}
@@ -635,10 +721,12 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
}
}
// First load: build everything in one innerHTML to avoid flicker
// 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.
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
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>`;
if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
@@ -689,6 +777,9 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
if (device.led_count) {
subtitleParts.push(`${device.led_count} LED`);
}
}
}
const cssId = target.color_strip_source_id || '';
@@ -700,6 +791,17 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
}
}
// Short channel label for the badge — first 2 chars of id hash after the
// `ot_` prefix, uppercased. Stable per target, consistent with the
// "CH·XX" convention in the mockup without needing a position counter.
const rawId = (target.id || '').replace(/^ot_/, '');
const chLabel = (rawId.slice(0, 2) || 'XX').toUpperCase();
const typeLabel2 = isLed
? ((target.device_id && devicesMap[target.device_id]?.device_type) || 'LED').toUpperCase()
: isHALight ? 'HA'
: 'KC';
const badgeText = `CH·${chLabel} · ${typeLabel2}`;
if (isRunning) {
const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
const fpsCurrent = isHALight ? fpsTarget : (state.fps_current ?? 0);
@@ -725,47 +827,55 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
}
const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(target.name)}</span>${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led on blink"></span>
<span class="led on blink"></span>
<span class="led on blink"></span>
</div>
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric dashboard-fps-metric">
<div class="dashboard-fps-sparkline">
<canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
<div class="mod-metrics">
<div class="mod-metric" title="${t('dashboard.fps') || 'FPS'}">
<span class="k">FPS</span>
<span class="v signal" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
<canvas class="mod-metric-spark-canvas" id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
</div>
<div class="dashboard-fps-label">
<span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
<div class="mod-metric" title="${t('dashboard.uptime')}">
<span class="k" data-i18n="dashboard.uptime">Uptime</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>
</div>
<div class="dashboard-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value" data-errors-text="${target.id}" title="${errors}">${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot is-live"></span><span>PATCHED</span></div>
<button class="mod-btn mod-btn-stop" onclick="event.stopPropagation(); dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN} <span>${t('device.button.stop') || 'Stop'}</span></button>
</div>
</div>`;
} else {
const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot"></span><span>STANDBY</span></div>
<button class="mod-btn mod-btn-go" onclick="event.stopPropagation(); dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START} <span>${t('device.button.start') || 'Start'}</span></button>
</div>
</div>`;
}
@@ -791,31 +901,41 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
condSummary = parts.join(logic);
}
const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('automations.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('automations.status.inactive')}</span>`;
// Scene info
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const short = (automation.id || '').replace(/^auto_/, '').slice(0, 2).toUpperCase() || 'AU';
const ledCls = isActive ? 'led on blink' : (isDisabled ? 'led' : 'led on');
const patchLabel = isDisabled
? (t('automations.status.disabled') || 'DISABLED').toUpperCase()
: isActive
? (t('automations.status.active') || 'ACTIVE').toUpperCase()
: (t('automations.status.inactive') || 'STANDBY').toUpperCase();
const patchLive = isActive ? ' is-live' : '';
const btnCls = automation.enabled ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = automation.enabled
? (t('automations.action.disable') || 'Disable')
: (t('automations.action.enable') || t('automations.status.active') || 'Enable');
const metaLines: string[] = [];
if (condSummary) metaLines.push(escapeHtml(condSummary));
metaLines.push(`${ICON_SCENE} ${sceneName}`);
const aStyle = cardColorStyle(automation.id);
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOMATION}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(automation.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
return `<div class="dashboard-target dashboard-automation dashboard-card-link ${isActive ? 'is-running' : ''}" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
<div class="mod-meta">${metaLines.join(' · ')}</div>
</div>
${statusBadge}
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${automation.enabled ? 'stop' : 'start'}" onclick="dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_STOP_PLAIN : ICON_START}
</button>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">${automation.enabled ? ICON_STOP_PLAIN : ICON_START} <span>${btnLabel}</span></button>
</div>
</div>`;
}
@@ -1,13 +1,13 @@
/**
* Performance charts real-time CPU, RAM, GPU usage with Chart.js.
* Supports system-wide and app-level (process) metrics with a toggle.
* History is seeded from the server-side ring buffer on init.
* Performance charts real-time CPU / RAM / GPU / Temp readouts.
*
* Rendered as inline SVG sparklines (no Chart.js dependency) so the card
* layout can put the big numeric value over the trend line like a studio
* instrument. History is seeded from the server-side ring buffer on init;
* each poll pushes a sample and re-renders the SVG path strings trivially
* cheap for 120-sample lines.
*/
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
window.Chart = Chart; // expose globally for targets.js, dashboard.js
import { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
@@ -15,69 +15,72 @@ import { isActiveTab } from '../core/tab-registry.ts';
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp'];
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps'] as const;
const PERF_MODE_KEY = 'perfMetricsMode';
const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
const SPARK_H = 64;
/** Metrics that don't have a per-process variant (host-only). */
const HOST_ONLY_KEYS = new Set(['temp']);
const HOST_ONLY_KEYS = new Set(['temp', 'fps']);
/** Default accent colors per metric — distinct hues for visual identity. */
const METRIC_COLORS: Record<string, string> = {
cpu: '#FF6B6B', // warm coral
ram: '#A855F7', // electric violet
gpu: '#10B981', // emerald teal
temp: '#FCD34D', // amber / heat
/** Default accent per metric maps to channel palette via CSS vars so the
perf cards share the same language as the rest of the app. Overrides
per-user in localStorage still honoured by `_getColor`. */
const METRIC_CSS_VARS: Record<string, string> = {
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
fps: '--ch-cyan',
};
/** Complementary app/process line colors — clearly different hue per metric. */
const APP_COLORS: Record<string, string> = {
cpu: '#FFB347', // amber
ram: '#60A5FA', // sky blue
gpu: '#34D399', // mint
/** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
const METRIC_FALLBACK: Record<string, string> = {
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
fps: '#00D8FF',
};
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _charts: Record<string, any> = {}; // { cpu, ram, gpu, temp }
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [] };
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
let _hasTemp: boolean | null = null; // null = unknown, true/false after first fetch
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [] };
/** Peak FPS observed during the session used as the y-axis ceiling for
* the FPS sparkline so slow targets look proportional to fast ones. */
let _fpsPeak = 60;
let _hasGpu: boolean | null = null;
let _hasTemp: boolean | null = null;
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`)
|| METRIC_COLORS[key]
|| '#4CAF50';
function _resolveCssVar(varName: string, fallback: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
return v || fallback;
} catch {
return fallback;
}
}
function _getAppColor(key: string): string {
return APP_COLORS[key] || _getColor(key) + '99';
function _getColor(key: string): string {
const override = localStorage.getItem(`perfChartColor_${key}`);
if (override) return override;
const cssVar = METRIC_CSS_VARS[key];
return cssVar ? _resolveCssVar(cssVar, METRIC_FALLBACK[key]) : METRIC_FALLBACK[key];
}
function _onChartColorChange(key: string, hex: string | null): void {
if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex);
} else {
// Reset: remove saved color, fall back to default
localStorage.removeItem(`perfChartColor_${key}`);
hex = _getColor(key);
// Update swatch to show the actual default color
const swatch = document.getElementById(`cp-swatch-perf-${key}`);
if (swatch) swatch.style.background = hex;
}
const chart = _charts[key];
if (chart) {
chart.data.datasets[0].borderColor = hex;
chart.data.datasets[0].backgroundColor = hex + '26';
// App line keeps its distinct hue (only update if user explicitly reset)
const appColor = _getAppColor(key);
chart.data.datasets[1].borderColor = appColor;
chart.data.datasets[1].backgroundColor = appColor + '1A';
chart.update();
}
// Update card accent color
_renderChartSvg(key);
const card = document.querySelector(`.perf-chart-card[data-metric="${key}"]`) as HTMLElement | null;
if (card) card.style.setProperty('--perf-accent', hex!);
}
@@ -96,250 +99,313 @@ export function setPerfMode(mode: PerfMode): void {
_mode = mode;
localStorage.setItem(PERF_MODE_KEY, mode);
// Update toggle button active states
document.querySelectorAll('.perf-mode-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
});
// Update dataset visibility on all charts
for (const key of CHART_KEYS) {
const chart = _charts[key];
if (!chart) continue;
const showSystem = mode === 'system' || mode === 'both';
const showApp = mode === 'app' || mode === 'both';
chart.data.datasets[0].hidden = !showSystem;
// Host-only metrics never have an app dataset to show.
chart.data.datasets[1].hidden = HOST_ONLY_KEYS.has(key) ? true : !showApp;
chart.update('none');
}
document.querySelectorAll('.perf-chart-card').forEach(card => {
(card as HTMLElement).dataset.perfMode = mode;
});
for (const key of CHART_KEYS) _renderChartSvg(key);
// Force an immediate poll so value + app-tag positioning updates now,
// rather than waiting for the next interval tick.
_fetchPerformance();
}
/** Returns the static HTML for the perf section (canvas placeholders). */
/** Returns the static HTML for the perf section. */
export function renderPerfSection(): string {
// Register callbacks before rendering
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"` : ''}>
<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>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-${key}-value"></span>
${key === 'cpu' || key === 'gpu' ? `<span class="perf-chart-subtitle" id="perf-${key}-name"></span>` : ''}
</div>
<div class="perf-chart-spark" id="perf-chart-${key}"></div>
</div>
</div>`;
const patchesCell = `
<div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value">
<span id="perf-patches-running">0</span><span class="perf-patches-sep"> / </span><span id="perf-patches-total" class="perf-patches-total">0</span>
</span>
</div>
<div class="perf-patches-list" id="perf-patches-list"></div>
</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">
<span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'}</span>
<span class="perf-chart-app" id="perf-fps-app" aria-hidden="true"></span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-fps-value"></span>
<span class="perf-chart-subtitle" id="perf-fps-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-fps"></div>
</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">
<span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value">
<span id="perf-devices-online">0</span><span class="perf-patches-sep"> / </span><span id="perf-devices-total" class="perf-patches-total">0</span>
</span>
<span class="perf-chart-subtitle" id="perf-devices-sub"></span>
</div>
<div class="perf-devices-dots" id="perf-devices-dots"></div>
</div>
</div>`;
return `<div class="perf-charts-grid">
<div class="perf-chart-card" data-metric="cpu">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card" data-metric="ram">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" data-metric="gpu" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>
</div>
<div class="perf-chart-card" data-metric="temp" id="perf-temp-card" hidden>
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.temp')} ${createColorPicker({ id: 'perf-temp', currentColor: _getColor('temp'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-temp-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-temp"></canvas></div>
</div>
${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>`;
}
function _createChart(canvasId: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!ctx) return null;
/** Externally-called from dashboard.ts whenever the running-target set
* is recomputed. Updates the Active Patches cell with count + a short
* list of running channels and their current FPS. */
export function updateActivePatches(
running: { id: string; name: string; fps?: number }[],
totalCount: number,
): void {
const rEl = document.getElementById('perf-patches-running');
const tEl = document.getElementById('perf-patches-total');
if (rEl) rEl.textContent = String(running.length).padStart(2, '0');
if (tEl) tEl.textContent = String(totalCount).padStart(2, '0');
const listEl = document.getElementById('perf-patches-list');
if (!listEl) return;
if (running.length === 0) {
listEl.innerHTML = '';
return;
}
const rows = running.slice(0, 4).map((r, i) => {
const colors = ['--ch-signal', '--ch-cyan', '--ch-magenta', '--ch-amber'];
const colorVar = colors[i % colors.length];
const fps = r.fps != null ? `${r.fps.toFixed(1)} FPS` : '—';
return `<div class="perf-patches-row">
<span class="perf-patches-stripe" style="background: var(${colorVar})"></span>
<span class="perf-patches-name">${escapeText(r.name)}</span>
<span class="perf-patches-fps">${fps}</span>
</div>`;
}).join('');
const more = running.length > 4 ? `<div class="perf-patches-more">+${running.length - 4} more</div>` : '';
listEl.innerHTML = rows + more;
}
function escapeText(s: string): string {
return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}
/** Total FPS cell pushed a new sample each dashboard refresh cycle.
* `totalFps` is the sum of fps_actual across running targets; `minFps`
* / `maxFps` are the live extremes shown as a subdued subtitle. */
export function updateTotalFps(totalFps: number, minFps: number | null, maxFps: number | null): void {
const fps = Math.max(0, totalFps);
_history.fps.push(fps);
if (_history.fps.length > MAX_SAMPLES) _history.fps.shift();
if (fps > _fpsPeak) _fpsPeak = fps;
const valEl = document.getElementById('perf-fps-value');
if (valEl) {
valEl.innerHTML = `${fps.toFixed(fps < 10 ? 1 : 0)}<span class="perf-fps-unit">fps</span>`;
}
const subEl = document.getElementById('perf-fps-sub');
if (subEl) {
if (minFps != null && maxFps != null && minFps !== maxFps) {
subEl.textContent = `min ${minFps.toFixed(1)} · max ${maxFps.toFixed(1)}`;
} else {
subEl.textContent = '';
}
}
_renderChartSvg('fps');
}
/** Devices cell — online / total count with a dot strip showing each
* device's connection state at a glance. */
export function updateDevices(
states: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[],
): void {
const total = states.length;
const online = states.filter(s => s.device_online).length;
const offline = total - online;
const onEl = document.getElementById('perf-devices-online');
const totEl = document.getElementById('perf-devices-total');
if (onEl) onEl.textContent = String(online).padStart(2, '0');
if (totEl) totEl.textContent = String(total).padStart(2, '0');
const subEl = document.getElementById('perf-devices-sub');
if (subEl) {
subEl.textContent = offline === 0
? 'all online'
: `${offline} offline`;
}
const dotsEl = document.getElementById('perf-devices-dots');
if (!dotsEl) return;
if (total === 0) {
dotsEl.innerHTML = '';
return;
}
// Cap visible dots to avoid wrapping weirdness; indicate overflow.
const MAX_DOTS = 24;
const shown = states.slice(0, MAX_DOTS);
const overflow = total - shown.length;
const dots = shown.map(s => {
const name = s.device_name || s.device_id;
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
const title = `${name}${s.device_online ? ' · online' : ' · offline'}${latency}`;
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
}).join('');
const more = overflow > 0
? `<span class="perf-devices-more">+${overflow}</span>`
: '';
dotsEl.innerHTML = dots + more;
}
/** Render the SVG sparkline into its container. */
function _renderChartSvg(key: string): void {
const host = document.getElementById(`perf-chart-${key}`);
if (!host) return;
const sys = _history[key] || [];
const app = _appHistory[key] || [];
const color = _getColor(key);
const appColor = _getAppColor(key);
const isHostOnly = HOST_ONLY_KEYS.has(key);
const showSystem = _mode === 'system' || _mode === 'both';
const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both');
return new Chart(ctx, {
type: 'line',
data: {
labels: Array(MAX_SAMPLES).fill(''),
datasets: [
{
// System-wide dataset — bold solid line
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 2,
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showSystem,
},
{
// App-level dataset — distinct hue, dashed
data: [],
borderColor: appColor,
backgroundColor: appColor + '1A',
borderWidth: 1.5,
borderDash: [4, 3],
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showApp,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { min: 0, max: 100, display: false },
},
layout: { padding: 0 },
},
});
// Scale y per metric — temp varies 20..90°C; fps uses a session peak
// with a 60 floor so a 30 FPS signal fills ~half the cell; others
// are 0..100 %.
const yMin = key === 'temp' ? 20 : 0;
const yMax = key === 'temp' ? 100
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1)
: 100;
const paths: string[] = [];
if (showSystem && sys.length > 1) {
paths.push(_pathFor(sys, yMin, yMax, color, 'sys'));
}
if (showApp && app.length > 1) {
paths.push(_pathFor(app, yMin, yMax, color, 'app'));
}
host.innerHTML = `
<svg class="perf-chart-svg" viewBox="0 0 ${SPARK_W} ${SPARK_H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="perf-fade-${key}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.32"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
</linearGradient>
</defs>
${paths.join('')}
</svg>`;
}
/** Seed charts from server-side metrics history. */
async function _seedFromServer(): Promise<void> {
try {
const data = await fetchMetricsHistory();
if (!data) return;
// The /system/metrics-history payload is loosely typed at the cache
// boundary — narrow `samples` here where we know the schema.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const samples: any[] = (data.system as any[] | undefined) || [];
_history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
_history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
_history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
_history.temp = samples.map((s: any) => s.cpu_temp).filter((v: any) => v != null);
_appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);
_appHistory.ram = samples.map((s: any) => s.app_ram).filter((v: any) => v != null);
_appHistory.gpu = samples.map((s: any) => s.app_gpu_mem).filter((v: any) => v != null);
/** Build <path> elements (area + stroke) for one series. */
function _pathFor(history: number[], yMin: number, yMax: number, color: string, kind: 'sys' | 'app'): 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;
const span = yMax - yMin || 1;
// Detect GPU availability from history. Only conclude "no GPU" when
// we actually have samples — an empty history shouldn't hide the
// card prematurely.
if (_history.gpu.length > 0) {
_hasGpu = true;
} else if (samples.length > 0) {
_hasGpu = false;
const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', '');
}
// Detect temperature availability from history; reveal the card now
// so the user doesn't see it appear/disappear after the first poll.
if (_history.temp.length > 0) {
_hasTemp = true;
const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden');
const points: string[] = [];
for (let i = 0; i < n; i++) {
const x = offset + i * step;
const v = Math.min(yMax, Math.max(yMin, history[i]));
const y = SPARK_H - ((v - yMin) / span) * (SPARK_H - 2) - 1;
points.push(`${x.toFixed(1)},${y.toFixed(1)}`);
}
const line = `M${points.join(' L')}`;
const firstX = offset;
const lastX = offset + (n - 1) * step;
const area = `M${firstX.toFixed(1)},${SPARK_H} L${points.join(' L')} L${lastX.toFixed(1)},${SPARK_H} Z`;
for (const key of CHART_KEYS) {
const chart = _charts[key];
if (!chart) continue;
// System dataset
if (_history[key].length > 0) {
chart.data.datasets[0].data = [..._history[key]];
}
// App dataset
if (_appHistory[key].length > 0) {
chart.data.datasets[1].data = [..._appHistory[key]];
}
// Align labels to the longer dataset
const maxLen = Math.max(chart.data.datasets[0].data.length, chart.data.datasets[1].data.length);
chart.data.labels = Array(maxLen).fill('');
chart.update();
}
} catch {
// Silently ignore — charts will fill from polling
}
}
/** Initialize Chart.js instances on the already-mounted canvases. */
export async function initPerfCharts(): Promise<void> {
_destroyCharts();
_charts.cpu = _createChart('perf-chart-cpu', 'cpu');
_charts.ram = _createChart('perf-chart-ram', 'ram');
_charts.gpu = _createChart('perf-chart-gpu', 'gpu');
_charts.temp = _createChart('perf-chart-temp', 'temp');
await _seedFromServer();
}
function _destroyCharts(): void {
for (const key of Object.keys(_charts)) {
if (_charts[key]) { _charts[key].destroy(); _charts[key] = null; }
if (kind === 'sys') {
const gradientId = `perf-fade-${color.replace(/[^a-z0-9]/gi, '')}`;
return `
<path d="${area}" fill="${color}" opacity="0.14" />
<path d="${line}" stroke="${color}" stroke-width="1.5" fill="none" stroke-linejoin="round" />`;
}
// App line: thinner, dashed, no fill
return `<path d="${line}" stroke="${color}" stroke-width="1.1" fill="none" stroke-dasharray="4 3" stroke-linejoin="round" opacity="0.75" />`;
}
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
// System history
_history[key].push(sysValue);
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
// App history
if (appValue != null) {
_appHistory[key].push(appValue);
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
}
const chart = _charts[key];
if (!chart) return;
// Update system dataset
const sysDs = chart.data.datasets[0].data;
sysDs.length = 0;
sysDs.push(..._history[key]);
// Update app dataset
const appDs = chart.data.datasets[1].data;
appDs.length = 0;
appDs.push(..._appHistory[key]);
// Ensure labels array matches the longer dataset
const maxLen = Math.max(sysDs.length, appDs.length);
while (chart.data.labels.length < maxLen) chart.data.labels.push('');
chart.data.labels.length = maxLen;
chart.update('none');
_renderChartSvg(key);
}
/** Format the value display based on mode.
* In "both" mode, returns HTML with a styled app-value tag. */
function _formatValue(sysVal: string, appVal: string | null): string {
if (_mode === 'system') return sysVal;
if (_mode === 'app') return appVal ?? '-';
// 'both': system value prominent, app value as subdued badge
if (appVal != null) return `${sysVal} <span class="perf-val-app">${appVal}</span>`;
return sysVal;
}
/** Apply formatted value — uses innerHTML when in "both" mode for styled tags. */
function _setValueEl(el: HTMLElement, html: string): void {
if (_mode === 'both') {
el.innerHTML = html;
/** Render the main + app values for a single perf card.
* - Main value ( `#perf-<key>-value` ) shows the value appropriate for the
* current mode (system value in 'system'/'both'; app value in 'app').
* - App tag ( `#perf-<key>-app` ) shows `APP <val>` in the top-right
* corner when mode is 'both' and an app value is available. */
function _renderValuePair(key: string, sysVal: string, appVal: string | null): void {
const mainEl = document.getElementById(`perf-${key}-value`);
if (mainEl) {
if (_mode === 'app' && appVal != null) {
mainEl.innerHTML = appVal;
} else {
el.textContent = html;
mainEl.innerHTML = sysVal;
}
}
const tagEl = document.getElementById(`perf-${key}-app`);
if (tagEl) {
if (_mode === 'both' && appVal != null) {
tagEl.innerHTML = `<span class="perf-chart-app-k">App</span>${appVal}`;
(tagEl as HTMLElement).style.display = '';
} else {
tagEl.textContent = '';
(tagEl as HTMLElement).style.display = 'none';
}
}
}
function _updateSidebarMeter(cpuPercent: number, appCpuPercent: number): void {
const cpuLabel = document.getElementById('sidebar-meter-cpu');
const cpuBar = document.getElementById('sidebar-meter-cpu-bar');
if (cpuLabel) cpuLabel.textContent = `${cpuPercent.toFixed(0)}%`;
if (cpuBar) (cpuBar as HTMLElement).style.width = `${Math.min(100, Math.max(0, cpuPercent))}%`;
// "FPS" bar — use app CPU share as a live utilization readout. When the
// dashboard later exposes an aggregate FPS ratio, swap this for that.
const fpsLabel = document.getElementById('sidebar-meter-fps');
const fpsBar = document.getElementById('sidebar-meter-fps-bar');
const appPct = cpuPercent > 0 ? Math.min(100, (appCpuPercent / cpuPercent) * 100) : 0;
if (fpsLabel) fpsLabel.textContent = `${appCpuPercent.toFixed(1)}%`;
if (fpsBar) (fpsBar as HTMLElement).style.width = `${appPct}%`;
}
async function _fetchPerformance(): Promise<void> {
@@ -348,90 +414,182 @@ async function _fetchPerformance(): Promise<void> {
if (!resp.ok) return;
const data = await resp.json();
// CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
// CPU
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
_updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0);
const cpuEl = document.getElementById('perf-cpu-value');
if (cpuEl) {
_setValueEl(cpuEl, _formatValue(
_renderValuePair('cpu',
`${data.cpu_percent.toFixed(0)}%`,
`${data.app_cpu_percent.toFixed(0)}%`
));
}
`${data.app_cpu_percent.toFixed(1)}%`);
if (data.cpu_name) {
const nameEl = document.getElementById('perf-cpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
}
// RAM — convert app_ram_mb to percent of total for consistent chart scale
// RAM
const appRamPct = data.ram_total_mb > 0
? (data.app_ram_mb / data.ram_total_mb) * 100
: 0;
_pushSample('ram', data.ram_percent, appRamPct);
const ramEl = document.getElementById('perf-ram-value');
if (ramEl) {
_updateTransportMem(data.app_ram_mb ?? 0);
const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_mb / 1024).toFixed(1);
const appMb = data.app_ram_mb.toFixed(0);
_setValueEl(ramEl, _formatValue(
_renderValuePair('ram',
`${usedGb}/${totalGb} GB`,
`${appMb} MB`
));
}
`${appMb} MB`);
// Temperature (host-only, no app variant)
// Temperature (host-only, no app variant — battery shown in app slot)
if (data.cpu_temp_c != null) {
if (_hasTemp !== true) {
_hasTemp = true;
const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden');
if (card) {
card.removeAttribute('hidden');
card.classList.remove('perf-chart-card-hint');
}
}
_pushSample('temp', data.cpu_temp_c, null);
const tempEl = document.getElementById('perf-temp-value');
if (tempEl) {
let display = `${data.cpu_temp_c.toFixed(0)}°C`;
if (data.battery_temp_c != null) {
display += ` <span class="perf-val-app">bat ${data.battery_temp_c.toFixed(0)}°C</span>`;
const batText = data.battery_temp_c != null
? `${data.battery_temp_c.toFixed(0)}°C`
: null;
_renderValuePair('temp',
`${data.cpu_temp_c.toFixed(0)}°C`,
batText);
} else if (data.cpu_temp_hint_key) {
// No live reading, but the server knows *why* — show an explainer
// in place of the big number instead of silently hiding the card.
// Primary use case: Windows without LibreHardwareMonitor, where
// no userland API exposes real CPU die temperature.
if (_hasTemp !== true) {
_hasTemp = true;
const card = document.getElementById('perf-temp-card');
if (card) {
card.removeAttribute('hidden');
card.classList.add('perf-chart-card-hint');
}
tempEl.innerHTML = display;
}
const valEl = document.getElementById('perf-temp-value');
if (valEl) {
valEl.innerHTML = `<span class="perf-chart-hint">${t(data.cpu_temp_hint_key)}</span>`;
}
const tagEl = document.getElementById('perf-temp-app');
if (tagEl) {
tagEl.textContent = '';
(tagEl as HTMLElement).style.display = 'none';
}
} else if (_hasTemp === null) {
// No temp data on first poll → backend doesn't expose it; keep card hidden.
_hasTemp = false;
}
// GPU
if (data.gpu) {
_hasGpu = true;
// GPU utilization is system-wide only (no per-process util from NVML)
// For app, show memory percentage if available
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);
const gpuEl = document.getElementById('perf-gpu-value');
if (gpuEl) {
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 VRAM`
? `${data.gpu.app_memory_mb.toFixed(0)}MB`
: null;
_setValueEl(gpuEl, _formatValue(sysText, appText));
}
_renderValuePair('gpu', sysText, appText);
if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;
}
} else if (_hasGpu === null) {
// No GPU info on first poll → backend doesn't expose it; hide the card.
_hasGpu = false;
const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', '');
}
} catch {
// Silently ignore fetch errors (e.g., network issues, tab hidden)
// Silently ignore transient fetch errors
}
}
/** Push CPU + app-CPU share to the sidebar meter plate. Called from
* `_fetchPerformance` because the sidebar meter shares the same poll. */
function _updateSidebarMeter(cpuPercent: number, appCpuPercent: number): void {
const cpuLabel = document.getElementById('sidebar-meter-cpu');
const cpuBar = document.getElementById('sidebar-meter-cpu-bar');
if (cpuLabel) cpuLabel.textContent = `${cpuPercent.toFixed(0)}%`;
if (cpuBar) (cpuBar as HTMLElement).style.width = `${Math.min(100, Math.max(0, cpuPercent))}%`;
const fpsLabel = document.getElementById('sidebar-meter-fps');
const fpsBar = document.getElementById('sidebar-meter-fps-bar');
const appPct = cpuPercent > 0 ? Math.min(100, (appCpuPercent / cpuPercent) * 100) : 0;
if (fpsLabel) fpsLabel.textContent = `${appCpuPercent.toFixed(1)}%`;
if (fpsBar) (fpsBar as HTMLElement).style.width = `${appPct}%`;
// Transport-bar meta cells — show APP CPU share (what LedGrab is
// consuming) rather than host CPU. System CPU stays on the sidebar
// meter when present.
const tCpu = document.getElementById('transport-cpu');
if (tCpu) tCpu.textContent = `${appCpuPercent.toFixed(1)}%`;
}
function _updateTransportMem(appRamMb: number): void {
const tMem = document.getElementById('transport-mem');
if (!tMem) return;
if (appRamMb >= 1024) {
tMem.textContent = `${(appRamMb / 1024).toFixed(1)}G`;
} else {
tMem.textContent = `${appRamMb.toFixed(0)}M`;
}
}
/** Seed history arrays from server-side ring buffer, then do an initial
* render so cards don't start empty on page load. */
async function _seedFromServer(): Promise<void> {
try {
const data = await fetchMetricsHistory();
if (!data) return;
const samples: any[] = (data.system as any[] | undefined) || [];
_history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
_history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
_history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
_history.temp = samples.map((s: any) => s.cpu_temp).filter((v: any) => v != null);
// app_cpu is already a percent (0..100 per-core), plot as-is.
_appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);
// app_ram + app_gpu_mem are absolute MB values in history — convert
// to a percent of their respective totals so they plot against the
// same 0..100 y-axis as the system line. If the total is missing
// on a given sample (e.g. GPU total isn't in the history schema),
// drop the point so it doesn't spike to the top.
_appHistory.ram = samples
.filter((s: any) => s.app_ram != null && s.ram_total > 0)
.map((s: any) => (s.app_ram / s.ram_total) * 100);
// Server history schema doesn't include gpu_memory_total — skip
// seeding the app GPU series and let the live poll fill it from
// the full /system/performance payload that does include totals.
_appHistory.gpu = [];
if (_history.gpu.length > 0) {
_hasGpu = true;
} else if (samples.length > 0) {
_hasGpu = false;
const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', '');
}
if (_history.temp.length > 0) {
_hasTemp = true;
const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden');
}
for (const key of CHART_KEYS) _renderChartSvg(key);
} catch {
// Charts will fill from polling instead
}
}
/** Initialize perf section — paint from server-side history. */
export async function initPerfCharts(): Promise<void> {
await _seedFromServer();
}
export function startPerfPolling(): void {
if (_pollTimer) return;
_fetchPerformance();
@@ -445,12 +603,10 @@ export function stopPerfPolling(): void {
}
}
// Pause polling when browser tab becomes hidden, resume when visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPerfPolling();
} else {
// Only resume if dashboard is active
if (isActiveTab('dashboard')) startPerfPolling();
}
});
@@ -129,23 +129,28 @@ export function renderScenePresetsSection(presets: ScenePreset[]): string | { he
function _renderDashboardPresetCard(preset: ScenePreset): string {
const targetCount = (preset.targets || []).length;
const subtitle = [
const metaParts = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
].filter(Boolean).join(' \u00b7 ');
preset.description ? escapeHtml(preset.description) : null,
].filter(Boolean);
const short = (preset.id || '').replace(/^scn_/, '').slice(0, 2).toUpperCase() || 'SC';
const activateLabel = t('scenes.activate') || 'Activate';
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
<div class="dashboard-target-subtitle">${subtitle}</div>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' \u00b7 ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot"></span><span>PRESET</span></div>
<button class="mod-btn mod-btn-go" data-action="activate-scene" data-id="${preset.id}" title="${activateLabel}" onclick="event.stopPropagation();">${ICON_START} <span>${activateLabel}</span></button>
</div>
</div>`;
}
@@ -66,6 +66,8 @@ export async function saveExternalUrl(): Promise<void> {
// ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
@@ -73,6 +75,8 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
// Remember so the next openSettingsModal() re-opens this tab.
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
// Lazy-render the appearance tab content
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
window.renderAppearanceTab();
@@ -285,8 +289,14 @@ function _getLogLevelItems(): { value: string; icon: string; label: string; desc
export function openSettingsModal(): void {
(document.getElementById('settings-error') as HTMLElement).style.display = 'none';
// Reset to first tab
switchSettingsTab('general');
// Restore last-opened tab (from localStorage) if the tab still exists;
// fall back to 'general' otherwise. Callers that want a specific tab
// (e.g. donation link → about, update badge → updates) call
// switchSettingsTab() themselves *after* opening.
let saved = 'general';
try { saved = localStorage.getItem(SETTINGS_ACTIVE_TAB_KEY) || 'general'; } catch { /* ignore */ }
if (!document.getElementById(`settings-panel-${saved}`)) saved = 'general';
switchSettingsTab(saved);
settingsModal.open();
@@ -55,7 +55,9 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
// Use window.* to avoid circular imports with feature modules
if (!skipLoad && isAuthed) callTabLoader(name);
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
// Perf poll keeps running across all tabs so the transport-bar
// Uptime / CPU / Mem cells stay live. Only stopped on auth loss
// or when the tab is hidden (visibilitychange handler).
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab
if (name !== 'targets') {
@@ -772,6 +772,11 @@
"sidebar.fps": "FPS",
"transport.status.ready": "Ready",
"transport.status.armed": "Armed · {n} live",
"transport.meta.uptime": "Uptime",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Mem",
"transport.meta.poll": "Poll",
"transport.meta.poll_hint": "Poll interval (click to cycle: 1s → 2s → 5s → 10s)",
"dashboard.title": "Dashboard",
"dashboard.section.targets": "Channels",
"dashboard.section.running": "Running",
@@ -791,10 +796,14 @@
"dashboard.section.integrations": "Integrations",
"dashboard.integrations.entities": "entities",
"dashboard.integrations.no_sources": "No integration sources configured",
"dashboard.perf.active_patches": "Active Patches",
"dashboard.perf.total_fps": "Total FPS",
"dashboard.perf.devices": "Devices",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "Temperature",
"dashboard.perf.temp.install_lhm": "Windows has no built-in CPU temperature API. Install LibreHardwareMonitor and enable \"Publish to WMI\" to see live readings here.",
"dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color",
"dashboard.perf.mode.system": "System",
@@ -756,6 +756,11 @@
"sidebar.fps": "FPS",
"transport.status.ready": "Готов",
"transport.status.armed": "Активно · {n}",
"transport.meta.uptime": "Время",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Память",
"transport.meta.poll": "Опрос",
"transport.meta.poll_hint": "Интервал опроса (клик: 1с → 2с → 5с → 10с)",
"dashboard.title": "Обзор",
"dashboard.section.targets": "Каналы",
"dashboard.section.running": "Запущенные",
@@ -772,10 +777,14 @@
"dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.active_patches": "Активные каналы",
"dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП",
"dashboard.perf.temp": "Температура",
"dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.",
"dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика",
"dashboard.perf.mode.system": "Система",
@@ -756,6 +756,11 @@
"sidebar.fps": "帧率",
"transport.status.ready": "就绪",
"transport.status.armed": "运行中 · {n}",
"transport.meta.uptime": "在线",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "内存",
"transport.meta.poll": "轮询",
"transport.meta.poll_hint": "轮询间隔(点击:1秒 → 2秒 → 5秒 → 10秒)",
"dashboard.title": "仪表盘",
"dashboard.section.targets": "通道",
"dashboard.section.running": "运行中",
@@ -772,10 +777,14 @@
"dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
"dashboard.perf.active_patches": "活动通道",
"dashboard.perf.total_fps": "总帧率",
"dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "内存",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "温度",
"dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。",
"dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统",
+84 -13
View File
@@ -38,8 +38,10 @@
<header>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<div class="brand-stack">
<h1 data-i18n="app.title">LED Grab</h1>
<span id="server-version"><span id="version-number"></span></span>
</div>
<span class="demo-badge" id="demo-badge" style="display:none" data-i18n="demo.badge">DEMO</span>
</div>
<div class="transport-center">
@@ -48,6 +50,27 @@
<span data-i18n="transport.status.ready">Ready</span>
</span>
</div>
<div class="transport-meta">
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.uptime">Uptime</span>
<span class="v" id="transport-uptime"></span>
</div>
<span class="meta-sep"></span>
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.cpu">CPU</span>
<span class="v" id="transport-cpu"></span>
</div>
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.mem">Mem</span>
<span class="v" id="transport-mem"></span>
</div>
<span class="meta-sep"></span>
<div class="meta-cell meta-cell-interactive" id="transport-poll" role="button" tabindex="0" data-i18n-title="transport.meta.poll_hint" title="Click to change poll interval">
<span class="k" data-i18n="transport.meta.poll">Poll</span>
<span class="v" id="transport-poll-value"></span>
</div>
<span class="meta-sep"></span>
</div>
<div class="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<span class="header-toolbar-sep"></span>
@@ -118,19 +141,6 @@
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
</div>
</div>
<div class="sidebar-foot">
<div class="cpu-meter" id="sidebar-meter" aria-hidden="true">
<div>
<div class="cpu-meter-row"><span data-i18n="sidebar.load">Load</span><b id="sidebar-meter-cpu">--%</b></div>
<div class="cpu-bar"><i id="sidebar-meter-cpu-bar" style="width:0"></i></div>
</div>
<div>
<div class="cpu-meter-row"><span data-i18n="sidebar.fps">FPS</span><b id="sidebar-meter-fps">--</b></div>
<div class="cpu-bar cpu-bar-fps"><i id="sidebar-meter-fps-bar" style="width:0"></i></div>
</div>
</div>
<div class="sidebar-version"><span id="sidebar-version-text"></span></div>
</div>
</aside>
<main class="app-main">
<div class="container">
@@ -534,6 +544,67 @@
// Initialize on load
updateAuthUI();
// Transport-bar session uptime ticker — time since page load.
(function() {
const pageLoadedAt = Date.now();
const el = document.getElementById('transport-uptime');
if (!el) return;
function pad(n) { return n < 10 ? '0' + n : String(n); }
function render() {
const secs = Math.floor((Date.now() - pageLoadedAt) / 1000);
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
el.textContent = `${pad(h)}:${pad(m)}:${pad(s)}`;
}
render();
setInterval(render, 1000);
})();
// Transport-bar poll-interval control — cycles through 1/2/5/10s
// presets on click. Affects dashboard refresh + perf polling, so
// it belongs in the global transport bar rather than the Dashboard
// toolbar.
(function() {
const PRESETS = [1000, 2000, 5000, 10000];
const KEY = 'dashboard_poll_interval';
const root = document.getElementById('transport-poll');
const valEl = document.getElementById('transport-poll-value');
if (!root || !valEl) return;
function render(ms) {
const s = Math.round(ms / 1000);
valEl.textContent = `${s}s`;
}
function apply(ms) {
localStorage.setItem(KEY, String(ms));
render(ms);
// Call the existing global hook if loaded (it also restarts
// auto-refresh + perf polling with the new interval).
if (typeof window.changeDashboardPollInterval === 'function') {
window.changeDashboardPollInterval(String(Math.round(ms / 1000)));
}
}
render(parseInt(localStorage.getItem(KEY), 10) || 2000);
function cycle(dir) {
const cur = parseInt(localStorage.getItem(KEY), 10) || 2000;
let idx = PRESETS.indexOf(cur);
if (idx < 0) idx = 1; // default to 2s if unknown
idx = (idx + (dir || 1) + PRESETS.length) % PRESETS.length;
apply(PRESETS[idx]);
}
root.addEventListener('click', function(e) { e.stopPropagation(); cycle(1); });
root.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cycle(1); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); cycle(1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); cycle(-1); }
});
})();
// Modal functions
function togglePasswordVisibility() {
const input = document.getElementById('api-key-input');
@@ -3,6 +3,11 @@
from __future__ import annotations
import os
import platform
import subprocess
import threading
import time
from typing import Optional
from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot
@@ -24,6 +29,14 @@ class PsutilMetricsProvider:
self._process = psutil_module.Process(os.getpid())
self._process.cpu_percent(interval=None)
self._cpu_count = int(psutil_module.cpu_count(logical=True) or 1)
# psutil has no sensors_temperatures() on Windows, so fall back to a
# throttled WMI/LHM reader running in a daemon thread. Disabled in
# tests via LEDGRAB_DISABLE_WIN_TEMP.
self._windows_temp: Optional[_WindowsCpuTemp] = (
_WindowsCpuTemp()
if platform.system() == "Windows" and not os.environ.get("LEDGRAB_DISABLE_WIN_TEMP")
else None
)
def cpu_percent(self) -> float:
return float(self._psutil.cpu_percent(interval=None))
@@ -80,8 +93,137 @@ class PsutilMetricsProvider:
except Exception:
pass
# Windows fallback: psutil exposes no CPU temperature there, so the
# reading would always be None without this. Other platforms keep
# the psutil result as-is.
if cpu_temp is None and self._windows_temp is not None:
cpu_temp = self._windows_temp.get()
return ThermalSnapshot(
battery_percent=battery_pct,
battery_temp_c=battery_temp,
cpu_temp_c=cpu_temp,
)
# ── Windows CPU temperature helper ───────────────────────────────────────
# Windows has no user-space API for real per-core CPU temperature without
# a vendor driver or third-party monitoring service, so we only try sources
# that reflect the actual CPU die rather than a motherboard/chassis zone:
#
# 1. LibreHardwareMonitor / OpenHardwareMonitor WMI — °C. Only usable when
# the monitoring app is running, but reads Intel DTS / AMD SMN directly
# so the reading actually tracks load.
# 2. ``MSAcpi_ThermalZoneTemperature`` WMI — Kelvin × 10. Some OEM boards
# wire this to the CPU; many require admin or expose a chassis zone
# instead. Only used as a last resort.
#
# The ``\Thermal Zone Information(*)\Temperature`` perf counter is
# deliberately NOT queried: on most consumer desktops it returns ACPI
# TZxx zones that are pinned at ~2730 °C regardless of CPU load — a
# misleading stable reading is worse than no reading at all.
#
# Emits a single numeric line on stdout and exits.
_WIN_TEMP_POWERSHELL = (
"$ErrorActionPreference='SilentlyContinue';"
"foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {"
" $lhm = Get-CimInstance -Namespace $ns -ClassName Sensor"
" -Filter \"SensorType='Temperature'\";"
" if ($lhm) {"
" $cpu = $lhm | Where-Object { $_.Parent -match 'cpu' -or $_.Name -match 'CPU' }"
" | Sort-Object Value -Descending | Select-Object -First 1;"
" if ($cpu) { '{0:N2}' -f $cpu.Value; exit }"
" }"
"}"
"$acpi = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature;"
"if ($acpi) {"
" $t = ($acpi | Measure-Object -Property CurrentTemperature -Maximum).Maximum;"
" if ($t) { '{0:N2}' -f ($t / 10.0 - 273.15); exit }"
"}"
)
def _query_windows_cpu_temp() -> Optional[float]:
"""Run the PowerShell WMI probe once and parse the single-line result.
Returns None on any failure. Rejects wildly out-of-range values to
guard against sensors that report raw (un-scaled) Kelvin or 0.
"""
if platform.system() != "Windows":
return None
try:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", _WIN_TEMP_POWERSHELL],
capture_output=True,
text=True,
timeout=4.0,
creationflags=creationflags,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
line = (result.stdout or "").strip().splitlines()
if not line:
return None
try:
# Locale may use comma as decimal separator (e.g. ru-RU).
temp = float(line[0].replace(",", ".").strip())
except ValueError:
return None
if -20.0 <= temp <= 150.0:
return temp
return None
class _WindowsCpuTemp:
"""Throttled background reader for Windows CPU temperature.
Spawning PowerShell costs hundreds of ms per call, so we refresh in a
daemon thread at most once every ``REFRESH_INTERVAL_S`` seconds and
return the most recent cached value from ``get()``. After
``MAX_FAILURES`` consecutive empty results we self-disable to avoid
launching PowerShell forever on hosts without any usable sensor.
"""
REFRESH_INTERVAL_S = 5.0
MAX_FAILURES = 3
def __init__(self) -> None:
self._cached_c: Optional[float] = None
self._last_refresh: float = 0.0
self._refreshing: bool = False
self._disabled: bool = False
self._failures: int = 0
self._lock = threading.Lock()
def get(self) -> Optional[float]:
if self._disabled:
return None
now = time.monotonic()
with self._lock:
due = now - self._last_refresh >= self.REFRESH_INTERVAL_S
should_start = due and not self._refreshing
if should_start:
self._refreshing = True
if should_start:
threading.Thread(target=self._refresh, daemon=True).start()
return self._cached_c
def _refresh(self) -> None:
try:
value = _query_windows_cpu_temp()
finally:
now = time.monotonic()
with self._lock:
self._last_refresh = now
self._refreshing = False
if value is not None:
self._cached_c = value
self._failures = 0
else:
self._failures += 1
if self._failures >= self.MAX_FAILURES:
self._disabled = True
+4 -1
View File
@@ -21,7 +21,10 @@ from ledgrab.utils.metrics import android_provider as android_mod
@pytest.fixture(autouse=True)
def _reset_provider_cache():
def _reset_provider_cache(monkeypatch):
# Disable the Windows CPU-temp background reader so tests don't spawn
# PowerShell when run on a Windows host.
monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1")
reset_metrics_provider()
yield
reset_metrics_provider()