e5a2af9821
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.
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""Tests for the metrics provider abstraction."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock, mock_open, patch
|
||
|
||
import pytest
|
||
|
||
from ledgrab.utils.metrics import (
|
||
AndroidMetricsProvider,
|
||
MemorySnapshot,
|
||
NullMetricsProvider,
|
||
ProcessSnapshot,
|
||
PsutilMetricsProvider,
|
||
ThermalSnapshot,
|
||
get_metrics_provider,
|
||
reset_metrics_provider,
|
||
)
|
||
from ledgrab.utils.metrics import android_provider as android_mod
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_provider_cache(monkeypatch):
|
||
# Disable the Windows CPU-temp background reader so tests don't spawn
|
||
# PowerShell when run on a Windows host.
|
||
monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1")
|
||
reset_metrics_provider()
|
||
yield
|
||
reset_metrics_provider()
|
||
|
||
|
||
def test_null_provider_returns_zero_metrics() -> None:
|
||
p = NullMetricsProvider()
|
||
assert p.available is False
|
||
assert p.cpu_percent() == 0.0
|
||
assert p.cpu_count() == 1
|
||
assert p.virtual_memory() == MemorySnapshot(0, 0, 0.0)
|
||
assert p.process_snapshot() == ProcessSnapshot(0.0, 0)
|
||
|
||
|
||
def test_psutil_provider_normalizes_process_cpu() -> None:
|
||
psutil_mock = MagicMock()
|
||
psutil_mock.cpu_percent.return_value = 42.5
|
||
psutil_mock.cpu_count.return_value = 8
|
||
mem = MagicMock(used=2_000_000, total=8_000_000, percent=25.0)
|
||
psutil_mock.virtual_memory.return_value = mem
|
||
proc_mock = MagicMock()
|
||
# Per-core 0–N*100% — 800% means all 8 cores fully busy → 100% normalized.
|
||
proc_mock.cpu_percent.return_value = 800.0
|
||
proc_mock.memory_info.return_value = MagicMock(rss=1_500_000)
|
||
psutil_mock.Process.return_value = proc_mock
|
||
|
||
provider = PsutilMetricsProvider(psutil_mock)
|
||
|
||
# Two priming calls expected at construction (host + process counters).
|
||
assert psutil_mock.cpu_percent.call_count == 1
|
||
assert proc_mock.cpu_percent.call_count == 1
|
||
|
||
assert provider.available is True
|
||
assert provider.cpu_percent() == 42.5
|
||
assert provider.cpu_count() == 8
|
||
|
||
snap = provider.virtual_memory()
|
||
assert snap.used_bytes == 2_000_000
|
||
assert snap.total_bytes == 8_000_000
|
||
assert snap.percent == 25.0
|
||
|
||
proc = provider.process_snapshot()
|
||
assert proc.cpu_percent == 100.0 # 800% / 8 cores
|
||
assert proc.rss_bytes == 1_500_000
|
||
|
||
|
||
def test_psutil_provider_handles_unknown_cpu_count() -> None:
|
||
psutil_mock = MagicMock()
|
||
psutil_mock.cpu_count.return_value = None # psutil sometimes returns None
|
||
psutil_mock.Process.return_value = MagicMock()
|
||
|
||
provider = PsutilMetricsProvider(psutil_mock)
|
||
|
||
assert provider.cpu_count() == 1 # falls back to 1 to avoid div-by-zero
|
||
|
||
|
||
def test_factory_returns_psutil_provider_when_available() -> None:
|
||
pytest.importorskip("psutil")
|
||
provider = get_metrics_provider()
|
||
assert isinstance(provider, PsutilMetricsProvider)
|
||
assert provider.available is True
|
||
# Same instance on subsequent calls — provider is cached.
|
||
assert get_metrics_provider() is provider
|
||
|
||
|
||
def test_factory_falls_back_to_null_when_psutil_missing(monkeypatch) -> None:
|
||
# Hide psutil from the import system for this test.
|
||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||
provider = get_metrics_provider()
|
||
assert isinstance(provider, NullMetricsProvider)
|
||
assert provider.available is False
|
||
|
||
|
||
# ── Android provider ────────────────────────────────────────────────
|
||
|
||
|
||
def test_android_meminfo_parses_kb_values(monkeypatch) -> None:
|
||
sample = (
|
||
"MemTotal: 2000000 kB\n"
|
||
"MemFree: 500000 kB\n"
|
||
"MemAvailable: 1500000 kB\n"
|
||
)
|
||
with patch("builtins.open", mock_open(read_data=sample)):
|
||
snap = android_mod._read_meminfo()
|
||
assert snap.total_bytes == 2000000 * 1024
|
||
# used = total - available
|
||
assert snap.used_bytes == 500000 * 1024
|
||
assert snap.percent == 25.0
|
||
|
||
|
||
def test_android_meminfo_returns_zero_on_missing_file(monkeypatch) -> None:
|
||
def _raise(*args, **kwargs):
|
||
raise OSError("simulated")
|
||
|
||
monkeypatch.setattr("builtins.open", _raise)
|
||
snap = android_mod._read_meminfo()
|
||
assert snap == MemorySnapshot(0, 0, 0.0)
|
||
|
||
|
||
def test_android_proc_self_stat_parses_with_paren_in_comm() -> None:
|
||
# Process name "(weird) name" — embedded parens are the classic /proc trap.
|
||
fields = ["S", "1", "1", "1", "0", "-1", "0", "0", "0", "0", "0", "150", "75"]
|
||
raw = b"42 ((weird) name) " + " ".join(fields).encode() + b"\n"
|
||
m = mock_open(read_data=raw)
|
||
with patch("builtins.open", m):
|
||
jiffies = android_mod._read_proc_self_stat_jiffies()
|
||
assert jiffies == 150 + 75
|
||
|
||
|
||
def test_android_provider_cpu_percent_uses_delta() -> None:
|
||
# First sample: total=1000, busy=200. Second sample: total=2000, busy=900.
|
||
# Delta busy/total = 700/1000 = 70%.
|
||
samples = iter(
|
||
[
|
||
android_mod._CpuSample(total=1000, busy=200),
|
||
android_mod._CpuSample(total=2000, busy=900),
|
||
]
|
||
)
|
||
with patch.object(android_mod, "_read_proc_stat", lambda: next(samples)):
|
||
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: 0):
|
||
provider = AndroidMetricsProvider()
|
||
assert provider.cpu_percent() == 70.0
|
||
|
||
|
||
def test_android_provider_process_cpu_normalized_across_cores() -> None:
|
||
# Process consumed 400 jiffies while host clock advanced 1000 jiffies
|
||
# across all cores → 40% of one CPU's worth of work.
|
||
host_samples = iter(
|
||
[
|
||
android_mod._CpuSample(total=1000, busy=500),
|
||
android_mod._CpuSample(total=2000, busy=1500),
|
||
]
|
||
)
|
||
proc_samples = iter([100, 500])
|
||
with patch.object(android_mod, "_read_proc_stat", lambda: next(host_samples)):
|
||
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: next(proc_samples)):
|
||
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 12345):
|
||
with patch.object(android_mod.os, "cpu_count", lambda: 4):
|
||
provider = AndroidMetricsProvider()
|
||
snap = provider.process_snapshot()
|
||
assert snap.cpu_percent == 40.0
|
||
assert snap.rss_bytes == 12345
|
||
|
||
|
||
def test_android_provider_handles_missing_proc_files() -> None:
|
||
with patch.object(android_mod, "_read_proc_stat", lambda: None):
|
||
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: None):
|
||
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 0):
|
||
provider = AndroidMetricsProvider()
|
||
# No samples available → 0.0, not an exception.
|
||
assert provider.cpu_percent() == 0.0
|
||
snap = provider.process_snapshot()
|
||
assert snap == ProcessSnapshot(0.0, 0)
|
||
|
||
|
||
def test_factory_prefers_android_when_running_on_android(monkeypatch) -> None:
|
||
monkeypatch.setattr("ledgrab.utils.metrics.is_android", lambda: True)
|
||
monkeypatch.setattr("ledgrab.utils.metrics._android_supported", lambda: True)
|
||
provider = get_metrics_provider()
|
||
assert isinstance(provider, AndroidMetricsProvider)
|
||
|
||
|
||
# ── Thermals ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_null_provider_thermals_are_all_none() -> None:
|
||
snap = NullMetricsProvider().thermals()
|
||
assert snap == ThermalSnapshot()
|
||
|
||
|
||
def test_psutil_provider_thermals_picks_hottest_sensor() -> None:
|
||
psutil_mock = MagicMock()
|
||
psutil_mock.Process.return_value = MagicMock()
|
||
psutil_mock.cpu_count.return_value = 4
|
||
bat = MagicMock(percent=78.0)
|
||
psutil_mock.sensors_battery.return_value = bat
|
||
psutil_mock.sensors_temperatures.return_value = {
|
||
"coretemp": [
|
||
MagicMock(current=55.0),
|
||
MagicMock(current=72.5),
|
||
],
|
||
"acpi": [MagicMock(current=40.0)],
|
||
}
|
||
provider = PsutilMetricsProvider(psutil_mock)
|
||
snap = provider.thermals()
|
||
assert snap.battery_percent == 78.0
|
||
assert snap.cpu_temp_c == 72.5 # hottest across all sensors
|
||
assert snap.battery_temp_c is None # psutil doesn't expose battery temp
|
||
|
||
|
||
def test_psutil_provider_thermals_handles_missing_sensors() -> None:
|
||
psutil_mock = MagicMock()
|
||
psutil_mock.Process.return_value = MagicMock()
|
||
psutil_mock.cpu_count.return_value = 1
|
||
# Strip the optional sensor methods entirely (e.g. Windows psutil).
|
||
del psutil_mock.sensors_battery
|
||
del psutil_mock.sensors_temperatures
|
||
provider = PsutilMetricsProvider(psutil_mock)
|
||
assert provider.thermals() == ThermalSnapshot()
|
||
|
||
|
||
def test_android_battery_parses_tenths_of_celsius(monkeypatch) -> None:
|
||
def _fake_int(path: str):
|
||
return {
|
||
"/sys/class/power_supply/battery/capacity": 78,
|
||
"/sys/class/power_supply/battery/temp": 312, # tenths of °C → 31.2°C
|
||
}.get(path)
|
||
|
||
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
|
||
pct, temp = android_mod._read_battery()
|
||
assert pct == 78.0
|
||
assert temp == 31.2
|
||
|
||
|
||
def test_android_cpu_temp_filters_non_cpu_zones_and_picks_hottest(monkeypatch) -> None:
|
||
monkeypatch.setattr(
|
||
"glob.glob",
|
||
lambda _: [
|
||
"/sys/class/thermal/thermal_zone0",
|
||
"/sys/class/thermal/thermal_zone1",
|
||
"/sys/class/thermal/thermal_zone2",
|
||
"/sys/class/thermal/thermal_zone3",
|
||
],
|
||
)
|
||
|
||
def _fake_text(path: str):
|
||
return {
|
||
"/sys/class/thermal/thermal_zone0/type": "battery",
|
||
"/sys/class/thermal/thermal_zone1/type": "cpu-thermal",
|
||
"/sys/class/thermal/thermal_zone2/type": "soc-max",
|
||
"/sys/class/thermal/thermal_zone3/type": "skin-therm",
|
||
}.get(path)
|
||
|
||
def _fake_int(path: str):
|
||
return {
|
||
# Battery & skin should be filtered out by zone type
|
||
"/sys/class/thermal/thermal_zone0/temp": 99000,
|
||
"/sys/class/thermal/thermal_zone1/temp": 52000, # 52°C
|
||
"/sys/class/thermal/thermal_zone2/temp": 67500, # 67.5°C ← hottest
|
||
"/sys/class/thermal/thermal_zone3/temp": 99000,
|
||
}.get(path)
|
||
|
||
monkeypatch.setattr(android_mod, "_read_text_file", _fake_text)
|
||
monkeypatch.setattr(android_mod, "_read_int_file", _fake_int)
|
||
|
||
assert android_mod._read_cpu_temp_c() == 67.5
|
||
|
||
|
||
def test_android_cpu_temp_rejects_nonsense_values(monkeypatch) -> None:
|
||
monkeypatch.setattr("glob.glob", lambda _: ["/sys/class/thermal/thermal_zone0"])
|
||
monkeypatch.setattr(android_mod, "_read_text_file", lambda _: "cpu-thermal")
|
||
# Some buggy zones report INT_MAX
|
||
monkeypatch.setattr(android_mod, "_read_int_file", lambda _: 2147483647)
|
||
assert android_mod._read_cpu_temp_c() is None
|