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: except Exception as e:
logger.debug("NVML query failed: %s", 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( return PerformanceResponse(
cpu_name=_cpu_name, cpu_name=_cpu_name,
cpu_percent=metrics.cpu_percent(), cpu_percent=metrics.cpu_percent(),
@@ -328,6 +337,7 @@ def get_system_performance(_: AuthRequired):
battery_percent=thermals.battery_percent, battery_percent=thermals.battery_percent,
battery_temp_c=thermals.battery_temp_c, battery_temp_c=thermals.battery_temp_c,
cpu_temp_c=thermals.cpu_temp_c, cpu_temp_c=thermals.cpu_temp_c,
cpu_temp_hint_key=cpu_temp_hint_key,
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
+9
View File
@@ -98,6 +98,15 @@ class PerformanceResponse(BaseModel):
default=None, default=None,
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)", 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") timestamp: datetime = Field(description="Measurement timestamp")
+26 -21
View File
@@ -99,9 +99,9 @@
/* Dark theme (default) */ /* Dark theme (default) */
[data-theme="dark"] { [data-theme="dark"] {
--bg-color: #1a1a1a; --bg-color: #000000;
--bg-secondary: #242424; --bg-secondary: #0a0b0d;
--card-bg: #2d2d2d; --card-bg: #101216;
--text-color: #e0e0e0; --text-color: #e0e0e0;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #999; --text-secondary: #999;
@@ -115,9 +115,9 @@
--input-bg: #1a1a2e; --input-bg: #1a1a2e;
color-scheme: dark; color-scheme: dark;
/* ── Lumenworks dark palette ── */ /* ── Lumenworks dark palette — page is pure black, cards elevate ── */
--lux-bg-0: #0a0b0d; --lux-bg-0: #000000;
--lux-bg-1: #101216; --lux-bg-1: #0e1014;
--lux-bg-2: #15181d; --lux-bg-2: #15181d;
--lux-bg-3: #1c2027; --lux-bg-3: #1c2027;
--lux-line: #232831; --lux-line: #232831;
@@ -127,9 +127,13 @@
--lux-ink-mute: #5b6473; --lux-ink-mute: #5b6473;
--lux-ink-faint:#3a414c; --lux-ink-faint:#3a414c;
/* Channel palette — consistent across tabs for entity types */ /* Channel palette — consistent across tabs for entity types.
--ch-signal: #00ff7a; /* capture / targets — primary */ --ch-signal tracks --primary-color so the accent color picker
--ch-signal-dim: #00b85a; 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-cyan: #00d8ff; /* data / sources / screen */
--ch-magenta: #ff4ade; /* audio / FFT */ --ch-magenta: #ff4ade; /* audio / FFT */
--ch-amber: #ffb800; /* autostart / pending */ --ch-amber: #ffb800; /* autostart / pending */
@@ -142,9 +146,9 @@
/* Light theme */ /* Light theme */
[data-theme="light"] { [data-theme="light"] {
--bg-color: #f5f5f5; --bg-color: #ffffff;
--bg-secondary: #eee; --bg-secondary: #fafbfc;
--card-bg: #ffffff; --card-bg: #f5f6f8;
--text-color: #333333; --text-color: #333333;
--text-primary: #333333; --text-primary: #333333;
--text-secondary: #595959; --text-secondary: #595959;
@@ -163,13 +167,13 @@
--primary-text: #2e7d32; --primary-text: #2e7d32;
color-scheme: light; color-scheme: light;
/* ── Lumenworks light palette — tuned for WCAG AA on white. /* ── Lumenworks light palette — page is pure white, cards slightly
Channel colors darkened vs dark theme so they read against off-white so the stripe + hairline border still read against
near-white surfaces. ── */ the page. WCAG AA tuned. ── */
--lux-bg-0: #f5f6f8; --lux-bg-0: #ffffff;
--lux-bg-1: #ffffff; --lux-bg-1: #f6f8fb;
--lux-bg-2: #fafbfc; --lux-bg-2: #eef1f5;
--lux-bg-3: #eef1f5; --lux-bg-3: #e4e8ee;
--lux-line: #dee3ea; --lux-line: #dee3ea;
--lux-line-bold:#c4ccd6; --lux-line-bold:#c4ccd6;
--lux-ink: #0f1419; --lux-ink: #0f1419;
@@ -177,8 +181,9 @@
--lux-ink-mute: #6b7684; --lux-ink-mute: #6b7684;
--lux-ink-faint:#a5afbc; --lux-ink-faint:#a5afbc;
--ch-signal: #008f3f; /* --ch-signal tracks --primary-color so the accent picker propagates. */
--ch-signal-dim: #006b2f; --ch-signal: var(--primary-color);
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
--ch-cyan: #006b88; --ch-cyan: #006b88;
--ch-magenta: #b01a99; --ch-magenta: #b01a99;
--ch-amber: #a56a00; --ch-amber: #a56a00;
+2 -4
View File
@@ -19,6 +19,7 @@ section {
min-height: 140px; min-height: 140px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
/* keep solid — same flat black/white language as real cards */
} }
/* Small corner bracket + left hairline so the skeleton reads as a module /* Small corner bracket + left hairline so the skeleton reads as a module
@@ -135,9 +136,7 @@ section {
.card { .card {
--ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */ --ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */
background: linear-gradient(180deg, background: var(--lux-bg-1, var(--card-bg));
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md)); border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px; padding: 18px 20px 16px;
@@ -203,7 +202,6 @@ section {
.card[data-scene-id], .card[data-scene-id],
.card.ch-violet { --ch: var(--ch-violet, #8b7eff); } .card.ch-violet { --ch: var(--ch-violet, #8b7eff); }
.card[data-card-type="integration"],
.card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); } .card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); }
.card[data-card-type="offline"], .card[data-card-type="offline"],
File diff suppressed because it is too large Load Diff
+255 -96
View File
@@ -4,7 +4,7 @@
header { header {
display: grid; display: grid;
grid-template-columns: var(--sidebar-width, 248px) 1fr auto; grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto;
align-items: center; align-items: center;
height: var(--transport-height, 60px); height: var(--transport-height, 60px);
padding: 0 16px 0 0; padding: 0 16px 0 0;
@@ -49,58 +49,96 @@ header::before {
/* Glowing LED brand mark. Rendered as a ::before on .header-title so no /* Glowing LED brand mark. Rendered as a ::before on .header-title so no
HTML change is required. The existing #server-status pulse dot sits HTML change is required. The existing #server-status pulse dot sits
inside as the "core" of the mark (see status-badge rule below). */ 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 { .header-title::before {
content: ''; content: '';
width: 26px; width: 28px;
height: 26px; height: 28px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 4px; border-radius: 4px;
background: var(--ch-signal, var(--primary-color)); background: var(--ch-signal, var(--primary-color));
box-shadow: box-shadow:
var(--lux-signal-glow, 0 0 14px color-mix(in srgb, var(--primary-color) 40%, transparent)), 0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.3); 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; position: relative;
animation: brandPulse 4s ease-in-out infinite;
} }
.header-title::after { .header-title::after {
content: ''; content: '';
position: absolute; position: absolute;
left: calc(18px + 7px); left: calc(18px + 8px);
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 12px; width: 12px;
height: 12px; height: 12px;
background: var(--lux-bg-0, var(--bg-color)); 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; 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 { h1 {
font-family: 'Orbitron', sans-serif; font-family: 'Orbitron', sans-serif;
font-size: 0.95rem; font-size: 1.25rem;
font-weight: 700; font-weight: 900;
letter-spacing: 0.2em; letter-spacing: 0.18em;
text-transform: uppercase; 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; paint-order: stroke fill;
background: linear-gradient( background: linear-gradient(
120deg, 90deg,
var(--primary-color) 0%, var(--lux-ink, #e6ebf2) 0%,
var(--primary-text-color) 35%, var(--ch-signal, var(--primary-color)) 50%,
var(--primary-color) 50%, var(--lux-ink, #e6ebf2) 100%
var(--primary-text-color) 65%,
var(--primary-color) 100%
); );
background-size: 250% 100%; background-size: 220% 100%;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
animation: titleShimmer 6s ease-in-out infinite; animation: titleShimmer 8s linear infinite;
line-height: 1; 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 { @keyframes titleShimmer {
0%, 100% { background-position: 100% 50%; } to { background-position: -220% 50%; }
50% { background-position: 0% 50%; }
} }
/* ── Transport center: reserved area for armed-status / master-stop / /* ── Transport center: reserved area for armed-status / master-stop /
@@ -120,31 +158,38 @@ h1 {
.transport-status { .transport-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 6px 12px; padding: 9px 18px;
background: var(--lux-bg-2, var(--bg-secondary)); background: var(--lux-bg-2, var(--bg-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px); border-radius: var(--lux-r-sm, 3px);
color: var(--lux-ink-dim, var(--text-secondary)); color: var(--lux-ink-dim, var(--text-secondary));
font-size: 0.68rem; font-family: var(--font-mono, inherit);
letter-spacing: 0.08em; font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; 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 { .transport-status.is-armed {
color: var(--ch-signal, var(--primary-color)); color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, 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)) 8%, 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 { .transport-status .dot {
width: 6px; height: 6px; width: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: currentColor; 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; animation: pulse 1.4s ease-in-out infinite;
flex-shrink: 0;
} }
.transport-status:not(.is-armed) .dot { .transport-status:not(.is-armed) .dot {
background: var(--lux-ink-faint, var(--text-muted)); background: var(--lux-ink-faint, var(--text-muted));
@@ -152,6 +197,77 @@ h1 {
animation: none; 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 { h2 {
margin-bottom: 20px; margin-bottom: 20px;
color: var(--text-color); color: var(--text-color);
@@ -161,18 +277,17 @@ h2 {
.header-toolbar { .header-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
background: var(--lux-bg-1, var(--card-bg)); background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: none;
border-radius: var(--lux-r-sm, 3px); padding: 0;
padding: 3px 4px;
} }
.header-toolbar-sep { .header-toolbar-sep {
width: 1px; width: 1px;
height: 16px; height: 20px;
background: var(--lux-line, var(--border-color)); background: var(--lux-line, var(--border-color));
margin: 0 3px; margin: 0 6px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -380,35 +495,27 @@ h2 {
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
/* #server-status is overlaid on the brand mark as a small LED pip that /* #server-status visual hidden — the brand mark itself carries the
changes color based on connection. Inline element rendered via text node connection state. When JS adds `.offline`, the mark shifts to coral
('●'); we hide the glyph and use ::before for a clean circular dot so via the :has() modifier on .header-title below. */
sizing is consistent across fonts. */
.status-badge { .status-badge {
position: absolute; display: none;
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;
} }
.status-badge.online { /* Brand mark reflects connection state. Default is the running-color
background: var(--ch-signal, var(--primary-color)); (tracks --ch-signal / --primary-color). When the server-status element
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-signal, var(--primary-color)); has `.offline`, override to coral so the header reads "disconnected"
} without needing a separate pip. */
.header-title:has(#server-status.offline)::before {
.status-badge.offline {
background: var(--ch-coral, var(--danger-color)); 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 { @keyframes pulse {
@@ -656,23 +763,35 @@ h2 {
} }
/* Header toolbar buttons */ /* Header toolbar buttons */
/* Header icon buttons — hairline-bordered squares with channel glow
on hover. Mirrors the mockup's `.icon-btn` treatment. */
.header-btn { .header-btn {
width: 30px;
height: 30px;
padding: 0;
background: transparent; background: transparent;
border: none; border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 4px 6px; border-radius: var(--lux-r-sm, 3px);
border-radius: 5px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--lux-ink-dim, var(--text-secondary));
transition: color 0.2s, background 0.2s; transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
display: inline-flex; display: inline-grid;
align-items: center; place-items: center;
line-height: 1; line-height: 1;
flex-shrink: 0;
} }
.header-btn:hover { .header-btn:hover {
color: var(--text-color); color: var(--lux-ink, var(--text-color));
background: var(--bg-secondary); 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 */ /* Reusable color picker popover */
@@ -814,8 +933,11 @@ h2 {
.cp-backdrop { .cp-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: radial-gradient(1000px 600px at 50% 30%,
backdrop-filter: blur(2px); 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; animation: fadeIn 0.15s ease-out;
} }
@@ -824,10 +946,13 @@ h2 {
width: 520px; width: 520px;
max-width: 90vw; max-width: 90vw;
max-height: 60vh; max-height: 60vh;
background: var(--card-bg); background: var(--lux-bg-1, var(--card-bg));
border: 1px solid var(--border-color); border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: 12px; border-radius: var(--lux-r-md, 12px);
box-shadow: 0 16px 48px var(--shadow-color); 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; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -835,6 +960,24 @@ h2 {
animation: cpSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1); 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 { @keyframes cpSlideDown {
from { opacity: 0; transform: translateY(-12px) scale(0.98); } from { opacity: 0; transform: translateY(-12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
@@ -842,18 +985,23 @@ h2 {
.cp-input { .cp-input {
width: 100%; width: 100%;
padding: 14px 16px; padding: 16px 18px 14px 18px;
border: none; border: none;
border-bottom: 1px solid var(--border-color); border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
background: transparent; background: transparent;
color: var(--text-color); color: var(--lux-ink, var(--text-color));
font-family: var(--font-body, inherit);
font-size: 1rem; font-size: 1rem;
outline: none; outline: none;
box-sizing: border-box; box-sizing: border-box;
letter-spacing: -0.005em;
} }
.cp-input::placeholder { .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 { .cp-results {
@@ -863,32 +1011,38 @@ h2 {
} }
.cp-group-header { .cp-group-header {
font-size: 0.7rem; font-family: var(--font-mono, inherit);
font-size: 0.58rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.22em;
color: var(--text-secondary); color: var(--lux-ink-mute, var(--text-secondary));
padding: 8px 16px 4px; padding: 10px 18px 4px;
} }
.cp-result { .cp-result {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 8px 16px; padding: 9px 18px;
cursor: pointer; cursor: pointer;
transition: background 0.1s; transition: background 0.1s;
position: relative; position: relative;
z-index: 1; z-index: 1;
color: var(--lux-ink-dim, var(--text-color));
} }
.cp-result:hover { .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 { .cp-result.cp-active {
background: var(--primary-color); background: linear-gradient(90deg,
color: var(--primary-contrast); 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 { .cp-result.cp-active .cp-detail {
@@ -914,8 +1068,10 @@ h2 {
.cp-detail { .cp-detail {
flex-shrink: 0; flex-shrink: 0;
font-size: 0.75rem; font-family: var(--font-mono, inherit);
color: var(--text-secondary); font-size: 0.66rem;
letter-spacing: 0.04em;
color: var(--lux-ink-mute, var(--text-secondary));
} }
.cp-running { .cp-running {
@@ -950,11 +1106,14 @@ h2 {
} }
.cp-footer { .cp-footer {
padding: 6px 16px; padding: 8px 18px;
border-top: 1px solid var(--border-color); border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
font-size: 0.7rem; font-family: var(--font-mono, inherit);
color: var(--text-secondary); font-size: 0.62rem;
letter-spacing: 0.08em;
color: var(--lux-ink-mute, var(--text-secondary));
text-align: center; 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 /* On narrow screens the brand column shrinks to just the mark; on phones
+6 -6
View File
@@ -81,7 +81,7 @@
border: none; border: none;
border-radius: var(--lux-r-sm, 3px); border-radius: var(--lux-r-sm, 3px);
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.78rem; font-size: 0.82rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--lux-ink-dim, var(--text-secondary)); color: var(--lux-ink-dim, var(--text-secondary));
@@ -139,12 +139,12 @@
background: var(--lux-bg-3, var(--border-color)); background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-mute, var(--text-secondary)); color: var(--lux-ink-mute, var(--text-secondary));
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.6rem; font-size: 0.62rem;
font-weight: 600; font-weight: 600;
padding: 1px 6px; padding: 1px 7px;
border-radius: 8px; border-radius: 10px;
min-width: 18px; min-width: 20px;
line-height: 1.3; line-height: 1.4;
text-align: center; text-align: center;
} }
.sidebar .tab-btn.active .tab-badge { .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 { .subtab-section-header {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.72rem; font-size: 0.82rem;
font-weight: 600; font-weight: 700;
color: var(--lux-ink-dim, var(--text-secondary)); color: var(--lux-ink-dim, var(--text-secondary));
margin: 0 0 14px 0; margin: 0 0 16px 0;
padding-bottom: 8px; padding-bottom: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.2em; letter-spacing: 0.25em;
border-bottom: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
position: relative; position: relative;
} }
+4
View File
@@ -797,6 +797,10 @@ document.addEventListener('DOMContentLoaded', async () => {
startEventsWS(); startEventsWS();
startEntityEventListeners(); startEntityEventListeners();
startAutoRefresh(); 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) // Initialize update checker (banner + WS listener)
initUpdateListener(); initUpdateListener();
@@ -3,11 +3,14 @@
* *
* Both dashboard.js and targets.js need nearly identical Chart.js line charts * 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 * for FPS visualization. This module provides a single factory so the config
* lives in one place. * lives in one place and owns the global Chart.js registration.
*
* Requires Chart.js to be registered globally (done by perf-charts.js).
*/ */
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; const DEFAULT_MAX_SAMPLES = 120;
/** Left-pad an array with nulls so it always has `maxSamples` entries. */ /** 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} * @returns {Chart|null}
*/ */
export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) { 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; if (!canvas) return null;
const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES; 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(''); 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) { window._cpToggle = function (id) {
// Close all other pickers first (and drop their card elevation) // Close all other pickers first (and drop their card elevation)
document.querySelectorAll('.color-picker-popover').forEach((p: Element) => { document.querySelectorAll('.color-picker-popover').forEach((p: Element) => {
@@ -108,6 +127,32 @@ window._cpToggle = function (id) {
pop.style.animation = 'none'; pop.style.animation = 'none';
pop.style.zIndex = '10000'; pop.style.zIndex = '10000';
pop.classList.add('cp-fixed'); 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 // Mark active dot
+222 -102
View File
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.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 { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts'; import { isActiveTab } from '../core/tab-registry.ts';
import { import {
@@ -265,12 +265,23 @@ function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`); const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue; if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed'); const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`; if (speedEl) speedEl.textContent = `${c.speed}×`;
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); 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) { if (btn) {
btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`; btn.className = c.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`); btn.setAttribute('onclick', `event.stopPropagation(); ${c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`}`);
btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START; 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 const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}` ? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected'); : 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}')}"> 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="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${ICON_HOME}</span> <div class="mod-id">
<div> <span class="mod-badge">HA · ${escapeHtml(short)}</span>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div> <div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div> <div class="mod-meta">${escapeHtml(subtitle)}</div>
</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>
</div>`; </div>`;
} }
@@ -301,20 +322,44 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'); 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 statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected'); 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}')}"> 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="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${ICON_RADIO}</span> <div class="mod-id">
<div> <span class="mod-badge">MQTT · ${escapeHtml(short)}</span>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div> <div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div> <div class="mod-meta">${subtitle}</div>
</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>
</div>`; </div>`;
} }
function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void { 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) { for (const conn of haStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`); const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
if (!card) continue; 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.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected')); : t('ha_source.disconnected'));
} }
const subtitle = card.querySelector('.dashboard-target-subtitle'); const meta = card.querySelector('.mod-meta');
if (subtitle) { if (meta) {
subtitle.textContent = conn.connected meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}` ? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected'); : t('ha_source.disconnected');
} }
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
} }
// Update MQTT integration cards
if (mqttStatus) { if (mqttStatus) {
for (const conn of mqttStatus.connections) { for (const conn of mqttStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`); 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.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected')); dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'));
} }
const subtitle = card.querySelector('.dashboard-target-subtitle'); const meta = card.querySelector('.mod-meta');
if (subtitle) { if (meta) {
subtitle.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected'); meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
} }
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
} }
} }
// Update section count badge // Update section count badge
@@ -363,41 +409,41 @@ function renderDashboardSyncClock(clock: SyncClock): string {
? `dashboardPauseClock('${clock.id}')` ? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`; : `dashboardResumeClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const metaParts = [
const subtitle = [ `<span class="dashboard-clock-speed">${clock.speed}×</span>`,
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
clock.description ? escapeHtml(clock.description) : '', 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); 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}"` : ''}> 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="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${ICON_CLOCK}</span> <div class="mod-id">
<div> <span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="dashboard-target-name">${escapeHtml(clock.name)}</div> <div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</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> </div>
<div class="dashboard-target-actions"> <div class="mod-foot">
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}"> <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
${clock.is_running ? ICON_PAUSE : ICON_START} <button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
</button> <button class="mod-btn" onclick="event.stopPropagation(); dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
${ICON_CLOCK}
</button>
</div> </div>
</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; 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 { export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce); clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => { _pollDebounce = setTimeout(() => {
const ms = parseInt(String(value), 10) * 1000; const ms = parseInt(String(value), 10) * 1000;
@@ -482,7 +528,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // 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[] => []), outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null), fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []), devicesCache.fetch().catch((): any[] => []),
@@ -493,8 +539,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/sync-clocks').catch(() => null), fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/status').catch(() => null), fetchWithAuth('/home-assistant/status').catch(() => null),
fetchWithAuth('/mqtt/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 automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const automations = automationsData.automations || []; const automations = automationsData.automations || [];
const devicesMap = {}; 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); const stopped = enriched.filter(t => !t.state || !t.state.processing);
updateTabBadge('targets', running.length); updateTabBadge('targets', running.length);
_updateTransportStatus(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) // Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(','); 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); updateTabBadge('automations', activeAutomations.length);
const sceneMap = new Map(scenePresets.map(s => [s.id, s])); const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join(''); const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
dynamicHtml += `<div class="dashboard-section"> dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)} ${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionContent('automations', automationItems)} ${_sectionContent('automations', automationGrid)}
</div>`; </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 isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect(); const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
const 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>`;
if (isFirstLoad) { if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section"> container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())} ${_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; const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) { if (device) {
subtitleParts.push((device.device_type || '').toUpperCase()); subtitleParts.push((device.device_type || '').toUpperCase());
if (device.led_count) {
subtitleParts.push(`${device.led_count} LED`);
}
} }
} }
const cssId = target.color_strip_source_id || ''; 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) { if (isRunning) {
const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-'; const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
const fpsCurrent = isHALight ? fpsTarget : (state.fps_current ?? 0); 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); const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}> return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${icon}</span> <div class="mod-id">
<div> <span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(target.name)}</span>${healthDot}</div> <div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</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> </div>
<div class="dashboard-target-metrics"> <div class="mod-metrics">
<div class="dashboard-metric dashboard-fps-metric"> <div class="mod-metric" title="${t('dashboard.fps') || 'FPS'}">
<div class="dashboard-fps-sparkline"> <span class="k">FPS</span>
<canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas> <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>
</div> <canvas class="mod-metric-spark-canvas" id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
<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>
</div> </div>
<div class="dashboard-metric" title="${t('dashboard.uptime')}"> <div class="mod-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div> <span class="k" data-i18n="dashboard.uptime">Uptime</span>
<span class="v" data-uptime-text="${target.id}">${uptime}</span>
</div> </div>
<div class="dashboard-metric" title="${t('dashboard.errors')}"> <div class="mod-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> <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> </div>
<div class="dashboard-target-actions"> <div class="mod-foot">
<button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button> <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>
</div>`; </div>`;
} else { } else {
const cStyle2 = cardColorStyle(target.id); const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}> return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${icon}</span> <div class="mod-id">
<div> <span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div> <div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</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> </div>
<div class="dashboard-target-metrics"></div> <div class="mod-foot">
<div class="dashboard-target-actions"> <div class="mod-patch"><span class="patch-dot"></span><span>STANDBY</span></div>
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button> <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>
</div>`; </div>`;
} }
@@ -791,31 +901,41 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
condSummary = parts.join(logic); 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 // Scene info
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null; 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 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); 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}"` : ''}> 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="dashboard-target-info"> <div class="mod-head">
<span class="dashboard-target-icon">${ICON_AUTOMATION}</span> <div class="mod-id">
<div> <span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
<div class="dashboard-target-name">${escapeHtml(automation.name)}</div> <div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''} <div class="mod-meta">${metaLines.join(' · ')}</div>
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div> </div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div> </div>
${statusBadge}
</div> </div>
<div class="dashboard-target-actions"> <div class="mod-foot">
<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')}"> <div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
${automation.enabled ? ICON_STOP_PLAIN : ICON_START} <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>
</button>
</div> </div>
</div>`; </div>`;
} }
@@ -1,13 +1,13 @@
/** /**
* Performance charts — real-time CPU, RAM, GPU usage with Chart.js. * Performance charts — real-time CPU / RAM / GPU / Temp readouts.
* Supports system-wide and app-level (process) metrics with a toggle. *
* History is seeded from the server-side ring buffer on init. * 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 { API_BASE, getHeaders, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.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'; import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
const MAX_SAMPLES = 120; 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 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). */ /** 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. */ /** Default accent per metric — maps to channel palette via CSS vars so the
const METRIC_COLORS: Record<string, string> = { perf cards share the same language as the rest of the app. Overrides
cpu: '#FF6B6B', // warm coral per-user in localStorage still honoured by `_getColor`. */
ram: '#A855F7', // electric violet const METRIC_CSS_VARS: Record<string, string> = {
gpu: '#10B981', // emerald teal cpu: '--ch-coral',
temp: '#FCD34D', // amber / heat ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
fps: '--ch-cyan',
}; };
/** Complementary app/process line colors — clearly different hue per metric. */ /** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
const APP_COLORS: Record<string, string> = { const METRIC_FALLBACK: Record<string, string> = {
cpu: '#FFB347', // amber cpu: '#FF6B6B',
ram: '#60A5FA', // sky blue ram: '#A855F7',
gpu: '#34D399', // mint gpu: '#10B981',
temp: '#FCD34D',
fps: '#00D8FF',
}; };
type PerfMode = 'system' | 'app' | 'both'; type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null; 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: [], fps: [] };
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [] }; let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [] }; /** Peak FPS observed during the session — used as the y-axis ceiling for
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch * the FPS sparkline so slow targets look proportional to fast ones. */
let _hasTemp: boolean | null = null; // null = unknown, true/false after first fetch let _fpsPeak = 60;
let _hasGpu: boolean | null = null;
let _hasTemp: boolean | null = null;
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'; let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
function _getColor(key: string): string { function _resolveCssVar(varName: string, fallback: string): string {
return localStorage.getItem(`perfChartColor_${key}`) try {
|| METRIC_COLORS[key] const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|| '#4CAF50'; return v || fallback;
} catch {
return fallback;
}
} }
function _getAppColor(key: string): string { function _getColor(key: string): string {
return APP_COLORS[key] || _getColor(key) + '99'; 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 { function _onChartColorChange(key: string, hex: string | null): void {
if (hex) { if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex); localStorage.setItem(`perfChartColor_${key}`, hex);
} else { } else {
// Reset: remove saved color, fall back to default
localStorage.removeItem(`perfChartColor_${key}`); localStorage.removeItem(`perfChartColor_${key}`);
hex = _getColor(key); hex = _getColor(key);
// Update swatch to show the actual default color
const swatch = document.getElementById(`cp-swatch-perf-${key}`); const swatch = document.getElementById(`cp-swatch-perf-${key}`);
if (swatch) swatch.style.background = hex; if (swatch) swatch.style.background = hex;
} }
const chart = _charts[key]; _renderChartSvg(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
const card = document.querySelector(`.perf-chart-card[data-metric="${key}"]`) as HTMLElement | null; const card = document.querySelector(`.perf-chart-card[data-metric="${key}"]`) as HTMLElement | null;
if (card) card.style.setProperty('--perf-accent', hex!); if (card) card.style.setProperty('--perf-accent', hex!);
} }
@@ -96,250 +99,313 @@ export function setPerfMode(mode: PerfMode): void {
_mode = mode; _mode = mode;
localStorage.setItem(PERF_MODE_KEY, mode); localStorage.setItem(PERF_MODE_KEY, mode);
// Update toggle button active states
document.querySelectorAll('.perf-mode-btn').forEach(btn => { document.querySelectorAll('.perf-mode-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode); btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
}); });
// Update dataset visibility on all charts document.querySelectorAll('.perf-chart-card').forEach(card => {
for (const key of CHART_KEYS) { (card as HTMLElement).dataset.perfMode = mode;
const chart = _charts[key]; });
if (!chart) continue;
const showSystem = mode === 'system' || mode === 'both'; for (const key of CHART_KEYS) _renderChartSvg(key);
const showApp = mode === 'app' || mode === 'both'; // Force an immediate poll so value + app-tag positioning updates now,
chart.data.datasets[0].hidden = !showSystem; // rather than waiting for the next interval tick.
// Host-only metrics never have an app dataset to show. _fetchPerformance();
chart.data.datasets[1].hidden = HOST_ONLY_KEYS.has(key) ? true : !showApp;
chart.update('none');
}
} }
/** Returns the static HTML for the perf section (canvas placeholders). */ /** Returns the static HTML for the perf section. */
export function renderPerfSection(): string { export function renderPerfSection(): string {
// Register callbacks before rendering
for (const key of CHART_KEYS) { for (const key of CHART_KEYS) {
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); 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"> return `<div class="perf-charts-grid">
<div class="perf-chart-card" data-metric="cpu"> ${patchesCell}
<div class="perf-chart-header"> ${fpsCell}
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span> ${devicesCell}
<span class="perf-chart-value" id="perf-cpu-value">-</span> ${card('cpu', 'dashboard.perf.cpu')}
</div> ${card('ram', 'dashboard.perf.ram')}
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div> ${card('gpu', 'dashboard.perf.gpu')}
</div> ${card('temp', 'dashboard.perf.temp', true)}
<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>
</div>`; </div>`;
} }
function _createChart(canvasId: string, key: string): any { /** Externally-called from dashboard.ts whenever the running-target set
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null; * is recomputed. Updates the Active Patches cell with count + a short
if (!ctx) return null; * 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 color = _getColor(key);
const appColor = _getAppColor(key);
const isHostOnly = HOST_ONLY_KEYS.has(key); const isHostOnly = HOST_ONLY_KEYS.has(key);
const showSystem = _mode === 'system' || _mode === 'both'; const showSystem = _mode === 'system' || _mode === 'both';
const showApp = !isHostOnly && (_mode === 'app' || _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 },
},
});
}
/** Seed charts from server-side metrics history. */ // Scale y per metric — temp varies 20..90°C; fps uses a session peak
async function _seedFromServer(): Promise<void> { // with a 60 floor so a 30 FPS signal fills ~half the cell; others
try { // are 0..100 %.
const data = await fetchMetricsHistory(); const yMin = key === 'temp' ? 20 : 0;
if (!data) return; const yMax = key === 'temp' ? 100
// The /system/metrics-history payload is loosely typed at the cache : key === 'fps' ? Math.max(60, _fpsPeak * 1.1)
// boundary — narrow `samples` here where we know the schema. : 100;
// 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);
// Detect GPU availability from history. Only conclude "no GPU" when const paths: string[] = [];
// we actually have samples — an empty history shouldn't hide the if (showSystem && sys.length > 1) {
// card prematurely. paths.push(_pathFor(sys, yMin, yMax, color, 'sys'));
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');
}
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
} }
} if (showApp && app.length > 1) {
paths.push(_pathFor(app, yMin, yMax, color, 'app'));
/** 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; }
} }
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>`;
}
/** 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;
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`;
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 { function _pushSample(key: string, sysValue: number, appValue: number | null): void {
// System history
_history[key].push(sysValue); _history[key].push(sysValue);
if (_history[key].length > MAX_SAMPLES) _history[key].shift(); if (_history[key].length > MAX_SAMPLES) _history[key].shift();
// App history
if (appValue != null) { if (appValue != null) {
_appHistory[key].push(appValue); _appHistory[key].push(appValue);
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift(); if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
} }
const chart = _charts[key]; _renderChartSvg(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');
} }
/** Format the value display based on mode. /** Render the main + app values for a single perf card.
* In "both" mode, returns HTML with a styled app-value tag. */ * - Main value ( `#perf-<key>-value` ) shows the value appropriate for the
function _formatValue(sysVal: string, appVal: string | null): string { * current mode (system value in 'system'/'both'; app value in 'app').
if (_mode === 'system') return sysVal; * - App tag ( `#perf-<key>-app` ) shows `APP <val>` in the top-right
if (_mode === 'app') return appVal ?? '-'; * corner when mode is 'both' and an app value is available. */
// 'both': system value prominent, app value as subdued badge function _renderValuePair(key: string, sysVal: string, appVal: string | null): void {
if (appVal != null) return `${sysVal} <span class="perf-val-app">${appVal}</span>`; const mainEl = document.getElementById(`perf-${key}-value`);
return sysVal; if (mainEl) {
} if (_mode === 'app' && appVal != null) {
mainEl.innerHTML = appVal;
/** Apply formatted value — uses innerHTML when in "both" mode for styled tags. */ } else {
function _setValueEl(el: HTMLElement, html: string): void { mainEl.innerHTML = sysVal;
if (_mode === 'both') { }
el.innerHTML = html; }
} else { const tagEl = document.getElementById(`perf-${key}-app`);
el.textContent = html; 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> { async function _fetchPerformance(): Promise<void> {
@@ -348,90 +414,182 @@ async function _fetchPerformance(): Promise<void> {
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); 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); _pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
_updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0); _updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0);
const cpuEl = document.getElementById('perf-cpu-value'); _renderValuePair('cpu',
if (cpuEl) { `${data.cpu_percent.toFixed(0)}%`,
_setValueEl(cpuEl, _formatValue( `${data.app_cpu_percent.toFixed(1)}%`);
`${data.cpu_percent.toFixed(0)}%`,
`${data.app_cpu_percent.toFixed(0)}%`
));
}
if (data.cpu_name) { if (data.cpu_name) {
const nameEl = document.getElementById('perf-cpu-name'); const nameEl = document.getElementById('perf-cpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.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 const appRamPct = data.ram_total_mb > 0
? (data.app_ram_mb / data.ram_total_mb) * 100 ? (data.app_ram_mb / data.ram_total_mb) * 100
: 0; : 0;
_pushSample('ram', data.ram_percent, appRamPct); _pushSample('ram', data.ram_percent, appRamPct);
const ramEl = document.getElementById('perf-ram-value'); _updateTransportMem(data.app_ram_mb ?? 0);
if (ramEl) { const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const usedGb = (data.ram_used_mb / 1024).toFixed(1); const totalGb = (data.ram_total_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_mb / 1024).toFixed(1); const appMb = data.app_ram_mb.toFixed(0);
const appMb = data.app_ram_mb.toFixed(0); _renderValuePair('ram',
_setValueEl(ramEl, _formatValue( `${usedGb}/${totalGb} GB`,
`${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 (data.cpu_temp_c != null) {
if (_hasTemp !== true) { if (_hasTemp !== true) {
_hasTemp = true; _hasTemp = true;
const card = document.getElementById('perf-temp-card'); 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); _pushSample('temp', data.cpu_temp_c, null);
const tempEl = document.getElementById('perf-temp-value'); const batText = data.battery_temp_c != null
if (tempEl) { ? `${data.battery_temp_c.toFixed(0)}°C`
let display = `${data.cpu_temp_c.toFixed(0)}°C`; : null;
if (data.battery_temp_c != null) { _renderValuePair('temp',
display += ` <span class="perf-val-app">bat ${data.battery_temp_c.toFixed(0)}°C</span>`; `${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) { } else if (_hasTemp === null) {
// No temp data on first poll → backend doesn't expose it; keep card hidden.
_hasTemp = false; _hasTemp = false;
} }
// GPU // GPU
if (data.gpu) { if (data.gpu) {
_hasGpu = true; _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) const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb)
? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100 ? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100
: null; : null;
_pushSample('gpu', data.gpu.utilization, appGpuPct); _pushSample('gpu', data.gpu.utilization, appGpuPct);
const gpuEl = document.getElementById('perf-gpu-value'); const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
if (gpuEl) { const appText = data.gpu.app_memory_mb != null
const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`; ? `${data.gpu.app_memory_mb.toFixed(0)}MB`
const appText = data.gpu.app_memory_mb != null : null;
? `${data.gpu.app_memory_mb.toFixed(0)} MB VRAM` _renderValuePair('gpu', sysText, appText);
: null;
_setValueEl(gpuEl, _formatValue(sysText, appText));
}
if (data.gpu.name) { if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name'); const nameEl = document.getElementById('perf-gpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name; if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;
} }
} else if (_hasGpu === null) { } else if (_hasGpu === null) {
// No GPU info on first poll → backend doesn't expose it; hide the card.
_hasGpu = false; _hasGpu = false;
const card = document.getElementById('perf-gpu-card'); const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', ''); if (card) card.setAttribute('hidden', '');
} }
} catch { } 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 { export function startPerfPolling(): void {
if (_pollTimer) return; if (_pollTimer) return;
_fetchPerformance(); _fetchPerformance();
@@ -445,12 +603,10 @@ export function stopPerfPolling(): void {
} }
} }
// Pause polling when browser tab becomes hidden, resume when visible
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (document.hidden) {
stopPerfPolling(); stopPerfPolling();
} else { } else {
// Only resume if dashboard is active
if (isActiveTab('dashboard')) startPerfPolling(); if (isActiveTab('dashboard')) startPerfPolling();
} }
}); });
@@ -129,23 +129,28 @@ export function renderScenePresetsSection(presets: ScenePreset[]): string | { he
function _renderDashboardPresetCard(preset: ScenePreset): string { function _renderDashboardPresetCard(preset: ScenePreset): string {
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const metaParts = [
const subtitle = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, 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); 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}"` : ''}> 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"> <div class="mod-head">
<span class="dashboard-target-icon">${ICON_SCENE}</span> <div class="mod-id">
<div> <span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div> <div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''} ${metaParts.length ? `<div class="mod-meta">${metaParts.join(' \u00b7 ')}</div>` : ''}
<div class="dashboard-target-subtitle">${subtitle}</div> </div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div> </div>
</div> </div>
<div class="dashboard-target-actions"> <div class="mod-foot">
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button> <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>
</div>`; </div>`;
} }
@@ -66,6 +66,8 @@ export async function saveExternalUrl(): Promise<void> {
// ─── Settings-modal tab switching ─────────────────────────── // ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void { export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-tab-btn').forEach(btn => { document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId); 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 => { document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`); 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 // Lazy-render the appearance tab content
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') { if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
window.renderAppearanceTab(); window.renderAppearanceTab();
@@ -285,8 +289,14 @@ function _getLogLevelItems(): { value: string; icon: string; label: string; desc
export function openSettingsModal(): void { export function openSettingsModal(): void {
(document.getElementById('settings-error') as HTMLElement).style.display = 'none'; (document.getElementById('settings-error') as HTMLElement).style.display = 'none';
// Reset to first tab // Restore last-opened tab (from localStorage) if the tab still exists;
switchSettingsTab('general'); // 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(); settingsModal.open();
@@ -55,7 +55,9 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
// Use window.* to avoid circular imports with feature modules // Use window.* to avoid circular imports with feature modules
if (!skipLoad && isAuthed) callTabLoader(name); if (!skipLoad && isAuthed) callTabLoader(name);
} else { } 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(); if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab // Clean up WebSockets when leaving targets tab
if (name !== 'targets') { if (name !== 'targets') {
@@ -772,6 +772,11 @@
"sidebar.fps": "FPS", "sidebar.fps": "FPS",
"transport.status.ready": "Ready", "transport.status.ready": "Ready",
"transport.status.armed": "Armed · {n} live", "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.title": "Dashboard",
"dashboard.section.targets": "Channels", "dashboard.section.targets": "Channels",
"dashboard.section.running": "Running", "dashboard.section.running": "Running",
@@ -791,10 +796,14 @@
"dashboard.section.integrations": "Integrations", "dashboard.section.integrations": "Integrations",
"dashboard.integrations.entities": "entities", "dashboard.integrations.entities": "entities",
"dashboard.integrations.no_sources": "No integration sources configured", "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.cpu": "CPU",
"dashboard.perf.ram": "RAM", "dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "Temperature", "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.unavailable": "unavailable",
"dashboard.perf.color": "Chart color", "dashboard.perf.color": "Chart color",
"dashboard.perf.mode.system": "System", "dashboard.perf.mode.system": "System",
@@ -756,6 +756,11 @@
"sidebar.fps": "FPS", "sidebar.fps": "FPS",
"transport.status.ready": "Готов", "transport.status.ready": "Готов",
"transport.status.armed": "Активно · {n}", "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.title": "Обзор",
"dashboard.section.targets": "Каналы", "dashboard.section.targets": "Каналы",
"dashboard.section.running": "Запущенные", "dashboard.section.running": "Запущенные",
@@ -772,10 +777,14 @@
"dashboard.section.sync_clocks": "Синхронные часы", "dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы", "dashboard.section.performance": "Производительность системы",
"dashboard.perf.active_patches": "Активные каналы",
"dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП", "dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ", "dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП", "dashboard.perf.gpu": "ГП",
"dashboard.perf.temp": "Температура", "dashboard.perf.temp": "Температура",
"dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.",
"dashboard.perf.unavailable": "недоступно", "dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика", "dashboard.perf.color": "Цвет графика",
"dashboard.perf.mode.system": "Система", "dashboard.perf.mode.system": "Система",
@@ -756,6 +756,11 @@
"sidebar.fps": "帧率", "sidebar.fps": "帧率",
"transport.status.ready": "就绪", "transport.status.ready": "就绪",
"transport.status.armed": "运行中 · {n}", "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.title": "仪表盘",
"dashboard.section.targets": "通道", "dashboard.section.targets": "通道",
"dashboard.section.running": "运行中", "dashboard.section.running": "运行中",
@@ -772,10 +777,14 @@
"dashboard.section.sync_clocks": "同步时钟", "dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标", "dashboard.targets": "目标",
"dashboard.section.performance": "系统性能", "dashboard.section.performance": "系统性能",
"dashboard.perf.active_patches": "活动通道",
"dashboard.perf.total_fps": "总帧率",
"dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "内存", "dashboard.perf.ram": "内存",
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "温度", "dashboard.perf.temp": "温度",
"dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。",
"dashboard.perf.unavailable": "不可用", "dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色", "dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统", "dashboard.perf.mode.system": "系统",
+86 -15
View File
@@ -38,8 +38,10 @@
<header> <header>
<div class="header-title"> <div class="header-title">
<span id="server-status" class="status-badge"></span> <span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">LED Grab</h1> <div class="brand-stack">
<span id="server-version"><span id="version-number"></span></span> <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> <span class="demo-badge" id="demo-badge" style="display:none" data-i18n="demo.badge">DEMO</span>
</div> </div>
<div class="transport-center"> <div class="transport-center">
@@ -48,6 +50,27 @@
<span data-i18n="transport.status.ready">Ready</span> <span data-i18n="transport.status.ready">Ready</span>
</span> </span>
</div> </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"> <div class="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a> <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> <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> <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> </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> </aside>
<main class="app-main"> <main class="app-main">
<div class="container"> <div class="container">
@@ -534,6 +544,67 @@
// Initialize on load // Initialize on load
updateAuthUI(); 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 // Modal functions
function togglePasswordVisibility() { function togglePasswordVisibility() {
const input = document.getElementById('api-key-input'); const input = document.getElementById('api-key-input');
@@ -3,6 +3,11 @@
from __future__ import annotations from __future__ import annotations
import os import os
import platform
import subprocess
import threading
import time
from typing import Optional
from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot
@@ -24,6 +29,14 @@ class PsutilMetricsProvider:
self._process = psutil_module.Process(os.getpid()) self._process = psutil_module.Process(os.getpid())
self._process.cpu_percent(interval=None) self._process.cpu_percent(interval=None)
self._cpu_count = int(psutil_module.cpu_count(logical=True) or 1) 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: def cpu_percent(self) -> float:
return float(self._psutil.cpu_percent(interval=None)) return float(self._psutil.cpu_percent(interval=None))
@@ -80,8 +93,137 @@ class PsutilMetricsProvider:
except Exception: except Exception:
pass 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( return ThermalSnapshot(
battery_percent=battery_pct, battery_percent=battery_pct,
battery_temp_c=battery_temp, battery_temp_c=battery_temp,
cpu_temp_c=cpu_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) @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() reset_metrics_provider()
yield yield
reset_metrics_provider() reset_metrics_provider()