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:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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": "系统",
|
||||||
|
|||||||
@@ -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 ~27–30 °C regardless of CPU load — a
|
||||||
|
# misleading stable reading is worse than no reading at all.
|
||||||
|
#
|
||||||
|
# Emits a single numeric line on stdout and exits.
|
||||||
|
_WIN_TEMP_POWERSHELL = (
|
||||||
|
"$ErrorActionPreference='SilentlyContinue';"
|
||||||
|
"foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {"
|
||||||
|
" $lhm = Get-CimInstance -Namespace $ns -ClassName Sensor"
|
||||||
|
" -Filter \"SensorType='Temperature'\";"
|
||||||
|
" if ($lhm) {"
|
||||||
|
" $cpu = $lhm | Where-Object { $_.Parent -match 'cpu' -or $_.Name -match 'CPU' }"
|
||||||
|
" | Sort-Object Value -Descending | Select-Object -First 1;"
|
||||||
|
" if ($cpu) { '{0:N2}' -f $cpu.Value; exit }"
|
||||||
|
" }"
|
||||||
|
"}"
|
||||||
|
"$acpi = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature;"
|
||||||
|
"if ($acpi) {"
|
||||||
|
" $t = ($acpi | Measure-Object -Property CurrentTemperature -Maximum).Maximum;"
|
||||||
|
" if ($t) { '{0:N2}' -f ($t / 10.0 - 273.15); exit }"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_windows_cpu_temp() -> Optional[float]:
|
||||||
|
"""Run the PowerShell WMI probe once and parse the single-line result.
|
||||||
|
|
||||||
|
Returns None on any failure. Rejects wildly out-of-range values to
|
||||||
|
guard against sensors that report raw (un-scaled) Kelvin or 0.
|
||||||
|
"""
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", _WIN_TEMP_POWERSHELL],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=4.0,
|
||||||
|
creationflags=creationflags,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
line = (result.stdout or "").strip().splitlines()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Locale may use comma as decimal separator (e.g. ru-RU).
|
||||||
|
temp = float(line[0].replace(",", ".").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if -20.0 <= temp <= 150.0:
|
||||||
|
return temp
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _WindowsCpuTemp:
|
||||||
|
"""Throttled background reader for Windows CPU temperature.
|
||||||
|
|
||||||
|
Spawning PowerShell costs hundreds of ms per call, so we refresh in a
|
||||||
|
daemon thread at most once every ``REFRESH_INTERVAL_S`` seconds and
|
||||||
|
return the most recent cached value from ``get()``. After
|
||||||
|
``MAX_FAILURES`` consecutive empty results we self-disable to avoid
|
||||||
|
launching PowerShell forever on hosts without any usable sensor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REFRESH_INTERVAL_S = 5.0
|
||||||
|
MAX_FAILURES = 3
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._cached_c: Optional[float] = None
|
||||||
|
self._last_refresh: float = 0.0
|
||||||
|
self._refreshing: bool = False
|
||||||
|
self._disabled: bool = False
|
||||||
|
self._failures: int = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self) -> Optional[float]:
|
||||||
|
if self._disabled:
|
||||||
|
return None
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
due = now - self._last_refresh >= self.REFRESH_INTERVAL_S
|
||||||
|
should_start = due and not self._refreshing
|
||||||
|
if should_start:
|
||||||
|
self._refreshing = True
|
||||||
|
if should_start:
|
||||||
|
threading.Thread(target=self._refresh, daemon=True).start()
|
||||||
|
return self._cached_c
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
try:
|
||||||
|
value = _query_windows_cpu_temp()
|
||||||
|
finally:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._last_refresh = now
|
||||||
|
self._refreshing = False
|
||||||
|
if value is not None:
|
||||||
|
self._cached_c = value
|
||||||
|
self._failures = 0
|
||||||
|
else:
|
||||||
|
self._failures += 1
|
||||||
|
if self._failures >= self.MAX_FAILURES:
|
||||||
|
self._disabled = True
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user