`;
-
- const captureFpsCell = `
+ case 'capture_fps':
+ return `
@@ -241,11 +270,25 @@ export function renderPerfSection(): string {
`;
-
- const errorsCell = `
+ case 'capture_fps_actual':
+ return `
+
`;
+ case 'errors':
+ return `
@@ -255,11 +298,53 @@ export function renderPerfSection(): string {
`;
-
- const devicesCell = `
+ case 'network':
+ return `
+
`;
+ case 'device_latency':
+ return `
+
`;
+ case 'send_timing':
+ return `
+
`;
+ case 'devices':
+ return `
@@ -271,28 +356,42 @@ export function renderPerfSection(): string {
`;
+ case 'cpu': return _sparkCardHtml('cpu', 'dashboard.perf.cpu', false);
+ case 'ram': return _sparkCardHtml('ram', 'dashboard.perf.ram', false);
+ case 'gpu': return _sparkCardHtml('gpu', 'dashboard.perf.gpu', false);
+ case 'temp': return _sparkCardHtml('temp', 'dashboard.perf.temp', true);
+ default: return null;
+ }
+}
- // Cell registry — what each layout key actually renders. Cells with
- // env-gated visibility (gpu, temp) start hidden and reveal themselves
- // when the server reports a real reading; user can also force them
- // hidden via Customize.
- const cellRenderers: Record
string> = {
- patches: () => patchesCell,
- fps: () => fpsCell,
- capture_fps: () => captureFpsCell,
- errors: () => errorsCell,
- devices: () => devicesCell,
- cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false),
- ram: () => sparkCard('ram', 'dashboard.perf.ram', false),
- gpu: () => sparkCard('gpu', 'dashboard.perf.gpu', false),
- temp: () => sparkCard('temp', 'dashboard.perf.temp', true),
- };
+/** Re-register color-picker callbacks for every colorable cell. Idempotent
+ * — overwrites the previous handler keyed by id. Called whenever the
+ * perf section is (re)initialised so newly-created cells get wired up. */
+function _registerPerfColorPickers(): void {
+ for (const key of ALL_COLORABLE_KEYS) {
+ registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
+ }
+}
+
+/** Build a fresh `.perf-chart-card` element from a key. */
+function _buildCellElement(key: string): HTMLElement | null {
+ const html = _renderCellHtml(key);
+ if (!html) return null;
+ const tmp = document.createElement('div');
+ tmp.innerHTML = html.trim();
+ return tmp.firstElementChild as HTMLElement | null;
+}
+
+/** Returns the static HTML for the perf section. */
+export function renderPerfSection(): string {
+ _syncMode();
+ _registerPerfColorPickers();
let cellsHtml = '';
for (const cell of getOrderedPerfCells()) {
if (!cell.visible) continue;
- const render = cellRenderers[cell.key];
- if (render) cellsHtml += render();
+ const html = _renderCellHtml(cell.key);
+ if (html) cellsHtml += html;
}
return `${cellsHtml}
`;
@@ -382,12 +481,12 @@ export function updateTotalFps(
_renderChartSvg('fps', /*animate=*/true);
}
-/** Total Capture FPS cell — pushed a new sample each dashboard refresh
- * cycle. `totalFps` is the sum of `fps_capture` (configured capture-side
- * rate) across running targets; `minFps` / `maxFps` are the live
- * extremes shown as a subdued subtitle. Mirrors `updateTotalFps` but
- * for the capture side, so multi-stream setups can see how much capture
- * work is being scheduled. */
+/** Total Source FPS cell — sum of every running target's upstream
+ * color-strip-source `target_fps` (picture/audio/gradient/effect/...).
+ * This is the *requested* tick rate of the pipeline feeding LEDs, not
+ * the measured throughput of any external capture — a static-color
+ * stream still ticks at its idle rate and contributes here. The
+ * internal key stays `capture_fps` for layout-storage compatibility. */
export function updateTotalCaptureFps(
totalFps: number,
minFps: number | null,
@@ -415,6 +514,184 @@ export function updateTotalCaptureFps(
_renderChartSvg('capture_fps', /*animate=*/true);
}
+/** Total Capture FPS cell — sum of *measured* new-frame rates from
+ * capture-backed streams (screen capture today; audio/api-input
+ * follow-up). Diverges from Total Source FPS when the upstream
+ * capture stalls — Source FPS reads "what was requested," Capture FPS
+ * reads "what actually arrived." `reportingCount` is how many running
+ * targets have a measured rate (i.e. are capture-backed); used as the
+ * subtitle so a synthetic-only setup reads "no captures" instead of
+ * silently sitting at 0. */
+export function updateTotalCaptureFpsActual(
+ totalFps: number,
+ targetSum: number,
+ reportingCount: number,
+): void {
+ _lastTotalCaptureFpsActualArgs = { totalFps, targetSum, reportingCount };
+ const fps = Math.max(0, totalFps);
+ _history.capture_fps_actual.push(fps);
+ if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
+ if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
+
+ const valEl = document.getElementById('perf-capture_fps_actual-value');
+ if (valEl) {
+ if (reportingCount === 0) {
+ valEl.innerHTML = 'no captures';
+ } else {
+ const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
+ const ceilingSuffix = targetSum > 0
+ ? `/ ${Math.round(targetSum)}`
+ : '';
+ valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`;
+ }
+ }
+ const subEl = document.getElementById('perf-capture_fps_actual-sub');
+ if (subEl) {
+ if (reportingCount === 0) {
+ subEl.textContent = '';
+ } else if (targetSum > 0) {
+ // Drop ratio reads "how far behind requested" — useful at-a-glance
+ // diagnostic for capture saturation.
+ const ratio = Math.max(0, Math.min(1, fps / targetSum));
+ subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
+ } else {
+ subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
+ }
+ }
+ _renderChartSvg('capture_fps_actual', /*animate=*/true);
+}
+
+/** Network throughput cell — bytes/sec the LED transport is moving
+ * across all running targets, plus a cumulative "X total" subtitle.
+ * Pairs with the Errors cell to triage where a slowdown lives:
+ * pipeline-side errors with low throughput → CPU/GPU bottleneck,
+ * pipeline running clean with throughput pinned → WiFi/wired
+ * saturated. The bytes counter approximates LED-payload only
+ * (protocol overhead is sub-5 % for any real LED count). */
+export function updateNetworkThroughput(
+ bytesPerSec: number,
+ totalBytes: number,
+): void {
+ _lastNetworkArgs = { bytesPerSec, totalBytes };
+ const bps = Math.max(0, bytesPerSec);
+ _history.network.push(bps);
+ if (_history.network.length > MAX_SAMPLES) _history.network.shift();
+ if (bps > _networkPeak) _networkPeak = bps;
+ _paintNetworkValue(bps, totalBytes);
+ _renderChartSvg('network', /*animate=*/true);
+}
+
+/** Device-latency cell — average ping latency across *online*
+ * devices, with the worst-offender max as a subtitle. A leading
+ * indicator of WiFi degradation that fires before frames start
+ * dropping; pairs with the Devices cell to pinpoint which device
+ * is misbehaving. */
+export function updateDeviceLatency(
+ avgMs: number | null,
+ maxMs: number | null,
+ onlineCount: number,
+ totalCount: number,
+): void {
+ _lastDeviceLatencyArgs = { avgMs, maxMs, onlineCount, totalCount };
+ const sample = avgMs != null && Number.isFinite(avgMs) ? Math.max(0, avgMs) : 0;
+ _history.device_latency.push(sample);
+ if (_history.device_latency.length > MAX_SAMPLES) _history.device_latency.shift();
+ if (sample > _deviceLatencyPeak) _deviceLatencyPeak = sample;
+ _paintDeviceLatencyValue(avgMs, maxMs, onlineCount, totalCount);
+ _renderChartSvg('device_latency', /*animate=*/true);
+}
+
+/** Send-timing cell — average and max time spent inside the LED
+ * client's send call across running targets. Climbs as a
+ * pre-failure signal when the network gets congested, several
+ * seconds before the Errors cell starts showing skipped frames. */
+export function updateSendTiming(
+ avgMs: number,
+ maxMs: number,
+ reportingCount: number,
+): void {
+ _lastSendTimingArgs = { avgMs, maxMs, reportingCount };
+ const sample = Math.max(0, avgMs);
+ _history.send_timing.push(sample);
+ if (_history.send_timing.length > MAX_SAMPLES) _history.send_timing.shift();
+ const peakSample = Math.max(sample, Math.max(0, maxMs));
+ if (peakSample > _sendTimingPeak) _sendTimingPeak = peakSample;
+ _paintSendTimingValue(avgMs, maxMs, reportingCount);
+ _renderChartSvg('send_timing', /*animate=*/true);
+}
+
+function _formatBytesPerSec(bps: number): { value: string; unit: string } {
+ if (bps >= 1024 * 1024) return { value: (bps / 1024 / 1024).toFixed(1), unit: 'MB/s' };
+ if (bps >= 1024) return { value: (bps / 1024).toFixed(1), unit: 'KB/s' };
+ return { value: bps.toFixed(0), unit: 'B/s' };
+}
+
+function _formatBytes(b: number): string {
+ if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
+ if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
+ if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`;
+ return `${b} B`;
+}
+
+function _paintNetworkValue(bytesPerSec: number, totalBytes: number): void {
+ const valEl = document.getElementById('perf-network-value');
+ if (valEl) {
+ const { value, unit } = _formatBytesPerSec(bytesPerSec);
+ valEl.innerHTML = `${value}${unit}`;
+ }
+ const subEl = document.getElementById('perf-network-sub');
+ if (subEl) {
+ subEl.textContent = totalBytes > 0 ? `${_formatBytes(totalBytes)} total` : '';
+ }
+}
+
+function _paintDeviceLatencyValue(
+ avgMs: number | null,
+ maxMs: number | null,
+ onlineCount: number,
+ totalCount: number,
+): void {
+ const valEl = document.getElementById('perf-device_latency-value');
+ if (valEl) {
+ if (totalCount === 0) {
+ valEl.innerHTML = 'no devices';
+ } else if (avgMs == null) {
+ valEl.innerHTML = 'offline';
+ } else {
+ const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
+ valEl.innerHTML = `${txt}ms`;
+ }
+ }
+ const subEl = document.getElementById('perf-device_latency-sub');
+ if (subEl) {
+ const parts: string[] = [];
+ if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} online`);
+ if (maxMs != null) parts.push(`max ${maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0)}ms`);
+ subEl.textContent = parts.join(' · ');
+ }
+}
+
+function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: number): void {
+ const valEl = document.getElementById('perf-send_timing-value');
+ if (valEl) {
+ if (reportingCount === 0) {
+ valEl.innerHTML = 'idle';
+ } else {
+ const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
+ valEl.innerHTML = `${txt}ms`;
+ }
+ }
+ const subEl = document.getElementById('perf-send_timing-sub');
+ if (subEl) {
+ if (reportingCount === 0) {
+ subEl.textContent = '';
+ } else {
+ const maxTxt = maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0);
+ subEl.textContent = `max ${maxTxt}ms · ${reportingCount} target${reportingCount > 1 ? 's' : ''}`;
+ }
+ }
+}
+
/** Errors cell — converts the cumulative `errors_count` and
* `frames_skipped` totals (summed across running targets) into rates by
* taking per-poll deltas. The card stays at "0" / muted accent when
@@ -601,7 +878,11 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
const yMax = key === 'temp' ? 100
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1)
: key === 'capture_fps' ? Math.max(60, _captureFpsPeak * 1.1)
+ : key === 'capture_fps_actual' ? Math.max(60, _captureFpsActualPeak * 1.1, _fpsTargetSum * 1.1)
: key === 'errors' ? Math.max(5, _errorsPeak * 1.2)
+ : key === 'network' ? Math.max(1024, _networkPeak * 1.1)
+ : key === 'device_latency' ? Math.max(50, _deviceLatencyPeak * 1.2)
+ : key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
: 100;
const paths: string[] = [];
@@ -884,6 +1165,12 @@ async function _seedFromServer(): Promise {
// the full /system/performance payload that does include totals.
_appHistory.gpu = [];
+ // FPS / Capture FPS / Errors aggregates — populated by the
+ // server-side ring buffer so these sparks survive page reloads
+ // (mirrors how CPU / RAM already work). Older payloads without
+ // these fields produce empty arrays; live polling fills them in.
+ _seedAggregateHistories(samples);
+
if (_history.gpu.length > 0) {
_hasGpu = true;
} else if (samples.length > 0) {
@@ -903,6 +1190,209 @@ async function _seedFromServer(): Promise {
}
}
+/** Reconstruct the FPS / Capture FPS / Errors history + value labels
+ * from the server's ring buffer. Each system snapshot since v1.x
+ * carries the running-target aggregate so we can rehydrate without
+ * waiting for the dashboard's batch fetch (which is what populated
+ * these previously and lost everything on every reload). */
+function _seedAggregateHistories(samples: any[]): void {
+ if (samples.length === 0) return;
+
+ const fpsSeries = samples
+ .map((s: any) => s.total_fps)
+ .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
+ if (fpsSeries.length > 0) {
+ _history.fps = fpsSeries.slice(-MAX_SAMPLES);
+ _fpsPeak = Math.max(60, ..._history.fps);
+ }
+
+ const captureSeries = samples
+ .map((s: any) => s.total_capture_fps)
+ .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
+ if (captureSeries.length > 0) {
+ _history.capture_fps = captureSeries.slice(-MAX_SAMPLES);
+ _captureFpsPeak = Math.max(60, ..._history.capture_fps);
+ }
+
+ const captureActualSeries = samples
+ .map((s: any) => s.total_capture_fps_actual)
+ .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
+ if (captureActualSeries.length > 0) {
+ _history.capture_fps_actual = captureActualSeries.slice(-MAX_SAMPLES);
+ _captureFpsActualPeak = Math.max(60, ..._history.capture_fps_actual);
+ }
+
+ // Errors history is a per-second rate. The server already does
+ // the delta math against its own running totals, so we just
+ // pull `errors_per_sec` straight through. Set the cumulative
+ // baseline so the dashboard's next live update doesn't synthesize
+ // a phantom spike from a stale "0 → live count" comparison.
+ const errorsSeries = samples
+ .map((s: any) => s.errors_per_sec)
+ .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
+ if (errorsSeries.length > 0) {
+ _history.errors = errorsSeries.slice(-MAX_SAMPLES);
+ _errorsPeak = Math.max(1, ..._history.errors);
+ }
+ const lastSample = samples[samples.length - 1];
+ if (typeof lastSample?.total_errors_count === 'number') {
+ _prevErrorsTotal = lastSample.total_errors_count;
+ }
+ if (typeof lastSample?.total_frames_skipped === 'number') {
+ _prevSkippedTotal = lastSample.total_frames_skipped;
+ }
+
+ // Latest target-sum is shown as a dashed reference line on the
+ // FPS spark — pin it so the chart doesn't redraw without the
+ // ceiling line for the brief window before dashboard.ts polls.
+ if (typeof lastSample?.total_fps_target === 'number' && lastSample.total_fps_target > 0) {
+ _fpsTargetSum = lastSample.total_fps_target;
+ }
+
+ // Paint value labels from the latest sample so the cards don't
+ // sit on "—" / "0" until the next dashboard poll. Mirrors what
+ // `_applyPerfDataToDom` does for CPU/RAM/GPU on the same load.
+ if (typeof lastSample?.total_fps === 'number') {
+ _paintFpsValue(lastSample.total_fps);
+ }
+ if (typeof lastSample?.total_capture_fps === 'number') {
+ _paintCaptureFpsValue(lastSample.total_capture_fps);
+ }
+ if (typeof lastSample?.total_capture_fps_actual === 'number') {
+ _paintCaptureFpsActualValue(
+ lastSample.total_capture_fps_actual,
+ typeof lastSample.total_fps_target === 'number' ? lastSample.total_fps_target : 0,
+ typeof lastSample.capture_actual_count === 'number' ? lastSample.capture_actual_count : 0,
+ );
+ }
+
+ // Network throughput history — direct passthrough from the
+ // server's per-second rate (computed from cumulative byte counter
+ // deltas, same shape as `errors_per_sec`).
+ const networkSeries = samples
+ .map((s: any) => s.bytes_per_sec)
+ .filter((v: any) => typeof v === 'number' && Number.isFinite(v));
+ if (networkSeries.length > 0) {
+ _history.network = networkSeries.slice(-MAX_SAMPLES);
+ _networkPeak = Math.max(1024, ..._history.network);
+ }
+ if (typeof lastSample?.bytes_per_sec === 'number') {
+ _paintNetworkValue(
+ lastSample.bytes_per_sec,
+ typeof lastSample.total_bytes_sent === 'number' ? lastSample.total_bytes_sent : 0,
+ );
+ }
+
+ // Device latency history — sparkline plots the avg-across-online
+ // series. `null` samples (no devices online) become 0 in the
+ // history so the spark drops to floor instead of going jagged.
+ const latencySeries = samples
+ .map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0)
+ if (latencySeries.length > 0) {
+ _history.device_latency = latencySeries.slice(-MAX_SAMPLES);
+ _deviceLatencyPeak = Math.max(50, ..._history.device_latency);
+ }
+ if (lastSample) {
+ _paintDeviceLatencyValue(
+ typeof lastSample.device_latency_avg_ms === 'number' ? lastSample.device_latency_avg_ms : null,
+ typeof lastSample.device_latency_max_ms === 'number' ? lastSample.device_latency_max_ms : null,
+ typeof lastSample.device_online_count === 'number' ? lastSample.device_online_count : 0,
+ typeof lastSample.device_total_count === 'number' ? lastSample.device_total_count : 0,
+ );
+ }
+
+ // Send-timing history — plots the avg series; max travels in
+ // the subtitle/tooltip but isn't a separate spark line to avoid
+ // adding visual noise.
+ const sendSeries = samples
+ .map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0)
+ if (sendSeries.length > 0) {
+ _history.send_timing = sendSeries.slice(-MAX_SAMPLES);
+ const maxes = samples
+ .map((s: any) => (typeof s.send_timing_max_ms === 'number' && Number.isFinite(s.send_timing_max_ms)) ? s.send_timing_max_ms : 0);
+ _sendTimingPeak = Math.max(20, ..._history.send_timing, ...maxes);
+ }
+ if (lastSample) {
+ _paintSendTimingValue(
+ typeof lastSample.send_timing_avg_ms === 'number' ? lastSample.send_timing_avg_ms : 0,
+ typeof lastSample.send_timing_max_ms === 'number' ? lastSample.send_timing_max_ms : 0,
+ typeof lastSample.send_timing_count === 'number' ? lastSample.send_timing_count : 0,
+ );
+ }
+ if (typeof lastSample?.errors_per_sec === 'number') {
+ _paintErrorsValue(
+ lastSample.errors_per_sec,
+ lastSample.total_errors_count ?? 0,
+ lastSample.skipped_per_sec ?? 0,
+ );
+ }
+}
+
+function _paintFpsValue(fps: number): void {
+ const valEl = document.getElementById('perf-fps-value');
+ if (!valEl) return;
+ const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
+ const ceilingSuffix = _fpsTargetSum > 0
+ ? `/ ${Math.round(_fpsTargetSum)}`
+ : '';
+ valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`;
+}
+
+function _paintCaptureFpsValue(fps: number): void {
+ const valEl = document.getElementById('perf-capture_fps-value');
+ if (!valEl) return;
+ const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
+ valEl.innerHTML = `${fpsText}fps`;
+}
+
+function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCount: number): void {
+ const valEl = document.getElementById('perf-capture_fps_actual-value');
+ if (valEl) {
+ if (reportingCount === 0) {
+ valEl.innerHTML = 'no captures';
+ } else {
+ const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
+ const ceilingSuffix = targetSum > 0
+ ? `/ ${Math.round(targetSum)}`
+ : '';
+ valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`;
+ }
+ }
+ const subEl = document.getElementById('perf-capture_fps_actual-sub');
+ if (subEl) {
+ if (reportingCount === 0) {
+ subEl.textContent = '';
+ } else if (targetSum > 0) {
+ const ratio = Math.max(0, Math.min(1, fps / targetSum));
+ subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
+ } else {
+ subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
+ }
+ }
+}
+
+function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate: number): void {
+ const card = document.querySelector('.perf-errors-cell') as HTMLElement | null;
+ if (card) card.classList.toggle('has-errors', totalErrors > 0 || errorsRate > 0);
+
+ const valEl = document.getElementById('perf-errors-value');
+ if (valEl) {
+ const rateText = errorsRate >= 10
+ ? errorsRate.toFixed(0)
+ : errorsRate >= 1
+ ? errorsRate.toFixed(1)
+ : '0';
+ valEl.innerHTML = `${rateText}/s`;
+ }
+ const subEl = document.getElementById('perf-errors-sub');
+ if (subEl) {
+ const parts: string[] = [];
+ if (totalErrors > 0) parts.push(`${totalErrors} total`);
+ if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
+ subEl.textContent = parts.join(' · ');
+ }
+}
+
/** Initialize perf section — paint from server-side history and wire up
* spark hover tooltips. Also fires one immediate `_fetchPerformance` so
* the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load
@@ -924,52 +1414,110 @@ export async function initPerfCharts(): Promise {
/** Re-render the perf grid in place after a layout change.
*
- * Replaces just the `.perf-charts-grid` element (cell count / order /
- * mode / window / yScale all read from the layout via `renderPerfSection`),
- * then replays the cached state into the new DOM:
- * - sparkline SVGs from the persistent `_history` arrays
- * - cpu/ram/gpu/temp value labels from `_lastFetchData`
- * - patches/total-fps/devices cells from cached external setter args
+ * Reconciles the existing `.perf-charts-grid` against the desired cell
+ * set + order from the layout. Cells that already exist are kept in
+ * place (or moved to a new index) — their DOM, listeners, color picker
+ * state, hidden-by-env state, and label values all survive intact. Only
+ * cells that were not visible before are freshly created; cells that
+ * disappeared from the layout are removed.
*
- * This avoids the full-dashboard innerHTML wipe that previously caused a
- * frame of layout flicker plus a window where every cell showed "0" /
- * "—" until the next dashboard fetch landed. */
+ * Replay of cached state targets only newly-created cells, so unchanged
+ * cells don't get a phantom-scroll animation from a fresh
+ * `update*FPS/Errors/Devices` call. Without this, every layout tweak
+ * (mode toggle, window change, color reset) would visibly reset every
+ * sparkline. */
export function rerenderPerfGrid(): void {
const wrapper = document.querySelector('.dashboard-perf-persistent');
if (!wrapper) return;
- const oldGrid = wrapper.querySelector('.perf-charts-grid');
- if (!oldGrid) return;
+ const grid = wrapper.querySelector('.perf-charts-grid');
+ if (!grid) return;
- // `renderPerfSection()` returns the entire `.perf-charts-grid` div.
- const tmp = document.createElement('div');
- tmp.innerHTML = renderPerfSection();
- const newGrid = tmp.firstElementChild;
- if (!newGrid) return;
- oldGrid.replaceWith(newGrid);
+ _syncMode();
+ _registerPerfColorPickers();
- // Sparks: paint from existing module-level history (no flash).
+ // Index existing cells by metric key so we can keep / move them
+ // instead of rebuilding their DOM.
+ const existing = new Map();
+ grid.querySelectorAll(':scope > .perf-chart-card[data-metric]').forEach(el => {
+ const k = el.dataset.metric;
+ if (k) existing.set(k, el);
+ });
+
+ // Compute desired keys (in order). Anything visible in the layout
+ // makes the cut; env-detection hides cards via the `hidden` attr,
+ // which is independent of layout visibility.
+ const desiredKeys: string[] = [];
+ for (const cell of getOrderedPerfCells()) {
+ if (cell.visible) desiredKeys.push(cell.key);
+ }
+
+ // First pass: walk desired order, taking existing elements where
+ // possible and creating new ones otherwise. Track which keys are
+ // newly created — those are the only ones that need a value replay.
+ const newKeys = new Set();
+ const ordered: HTMLElement[] = [];
+ for (const key of desiredKeys) {
+ let el = existing.get(key);
+ if (el) {
+ existing.delete(key);
+ } else {
+ el = _buildCellElement(key) ?? undefined;
+ if (!el) continue;
+ newKeys.add(key);
+ }
+ // Update mode + accent on every kept cell. Cheap and idempotent;
+ // covers the common "mode toggle" path where cell set is unchanged.
+ el.dataset.perfMode = _mode;
+ el.style.setProperty('--perf-accent', _getColor(key));
+ ordered.push(el);
+ }
+
+ // Remove cells that should no longer be visible.
+ existing.forEach(el => el.remove());
+
+ // Reorder via appendChild — moving an already-attached node preserves
+ // its state (listeners, animation transforms, hidden attr). Skip the
+ // append when the node is already at the correct position to avoid
+ // gratuitous DOM mutations.
+ let cursor: ChildNode | null = grid.firstChild;
+ for (const el of ordered) {
+ if (cursor === el) {
+ cursor = el.nextSibling;
+ } else {
+ grid.insertBefore(el, cursor);
+ // `el` is now positioned before `cursor`; cursor stays the same.
+ }
+ }
+
+ // Re-render every spark (window / yScale may have changed). Pass
+ // `animate=false` so we don't trigger phantom scrolls — only real
+ // poll samples animate.
for (const key of CHART_KEYS) _renderChartSvg(key);
- // Re-apply env-detection visibility (the new HTML always renders
- // gpu/temp cells without the hidden attr; cached `_hasGpu/_hasTemp`
- // tell us what to actually do).
- if (_hasGpu === false) {
+ // Re-apply env-detection visibility for newly-created gpu/temp cells
+ // (the HTML template renders them unhidden by default).
+ if (newKeys.has('gpu') && _hasGpu === false) {
const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', '');
}
- if (_hasTemp === true) {
+ if (newKeys.has('temp') && _hasTemp === true) {
const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden');
}
- // Replay cached values so labels show real numbers, not "—".
- if (_lastFetchData) {
+ // Replay cached state only into newly-created cells. Existing cells
+ // already have correct labels in their DOM.
+ if (newKeys.size === 0) return;
+
+ const needsFetchReplay = newKeys.has('cpu') || newKeys.has('ram')
+ || newKeys.has('gpu') || newKeys.has('temp');
+ if (needsFetchReplay && _lastFetchData) {
_applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false);
}
- if (_lastPatchesArgs) {
+ if (newKeys.has('patches') && _lastPatchesArgs) {
updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount);
}
- if (_lastTotalFpsArgs) {
+ if (newKeys.has('fps') && _lastTotalFpsArgs) {
updateTotalFps(
_lastTotalFpsArgs.totalFps,
_lastTotalFpsArgs.minFps,
@@ -977,14 +1525,42 @@ export function rerenderPerfGrid(): void {
_lastTotalFpsArgs.targetSum,
);
}
- if (_lastTotalCaptureFpsArgs) {
+ if (newKeys.has('capture_fps') && _lastTotalCaptureFpsArgs) {
updateTotalCaptureFps(
_lastTotalCaptureFpsArgs.totalFps,
_lastTotalCaptureFpsArgs.minFps,
_lastTotalCaptureFpsArgs.maxFps,
);
}
- if (_lastErrorsArgs) {
+ if (newKeys.has('capture_fps_actual') && _lastTotalCaptureFpsActualArgs) {
+ updateTotalCaptureFpsActual(
+ _lastTotalCaptureFpsActualArgs.totalFps,
+ _lastTotalCaptureFpsActualArgs.targetSum,
+ _lastTotalCaptureFpsActualArgs.reportingCount,
+ );
+ }
+ if (newKeys.has('network') && _lastNetworkArgs) {
+ updateNetworkThroughput(
+ _lastNetworkArgs.bytesPerSec,
+ _lastNetworkArgs.totalBytes,
+ );
+ }
+ if (newKeys.has('device_latency') && _lastDeviceLatencyArgs) {
+ updateDeviceLatency(
+ _lastDeviceLatencyArgs.avgMs,
+ _lastDeviceLatencyArgs.maxMs,
+ _lastDeviceLatencyArgs.onlineCount,
+ _lastDeviceLatencyArgs.totalCount,
+ );
+ }
+ if (newKeys.has('send_timing') && _lastSendTimingArgs) {
+ updateSendTiming(
+ _lastSendTimingArgs.avgMs,
+ _lastSendTimingArgs.maxMs,
+ _lastSendTimingArgs.reportingCount,
+ );
+ }
+ if (newKeys.has('errors') && _lastErrorsArgs) {
// Replay must not synthesize a fake spike from delta against an
// older baseline (e.g. layout-change re-render after a long
// session). Pin the baseline to the cached totals so the call
@@ -997,7 +1573,7 @@ export function rerenderPerfGrid(): void {
_lastErrorsArgs.pollMs,
);
}
- if (_lastDevicesArgs) {
+ if (newKeys.has('devices') && _lastDevicesArgs) {
updateDevices(_lastDevicesArgs);
}
}
@@ -1026,7 +1602,13 @@ function _ensureTooltip(): HTMLDivElement {
/** Format a sampled value per metric for the tooltip line. */
function _formatSampleValue(key: string, v: number): string {
if (key === 'temp') return `${v.toFixed(1)}°C`;
- if (key === 'fps' || key === 'capture_fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
+ if (key === 'fps' || key === 'capture_fps' || key === 'capture_fps_actual') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
+ if (key === 'network') {
+ if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`;
+ if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`;
+ return `${v.toFixed(0)} B/s`;
+ }
+ if (key === 'device_latency' || key === 'send_timing') return `${v.toFixed(v < 10 ? 1 : 0)} ms`;
if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`;
return `${v.toFixed(1)}%`;
}
@@ -1037,7 +1619,11 @@ function _metricLabel(key: string): string {
if (key === 'gpu') return 'GPU';
if (key === 'temp') return 'Temp';
if (key === 'fps') return 'Total FPS';
- if (key === 'capture_fps') return 'Total Capture FPS';
+ if (key === 'capture_fps') return 'Total Source FPS';
+ if (key === 'capture_fps_actual') return 'Total Capture FPS';
+ if (key === 'network') return 'Network';
+ if (key === 'device_latency') return 'Device Latency';
+ if (key === 'send_timing') return 'Send Timing';
if (key === 'errors') return 'Errors';
return key.toUpperCase();
}
diff --git a/server/src/ledgrab/static/js/features/scene-presets.ts b/server/src/ledgrab/static/js/features/scene-presets.ts
index ceaa76e..9ecd30f 100644
--- a/server/src/ledgrab/static/js/features/scene-presets.ts
+++ b/server/src/ledgrab/static/js/features/scene-presets.ts
@@ -9,11 +9,12 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import {
- ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_TRASH, ICON_LINK,
+ ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK,
} from '../core/icons.ts';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
-import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
+import { wrapCard, cardColorStyle } from '../core/card-colors.ts';
+import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -81,35 +82,81 @@ export function createSceneCard(preset: ScenePreset) {
const automations = automationsCacheObj.data || [];
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
- const meta = [
- targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
- usedByCount > 0 ? `${ICON_LINK} ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
- ].filter(Boolean);
-
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
- const colorStyle = cardColorStyle(preset.id);
- return `
-
-
-
-
- ${preset.description ? `
${escapeHtml(preset.description)}
` : ''}
-
- ${meta.map(m => `${m}`).join('')}
- ${updated ? `${updated}` : ''}
-
- ${renderTagChips(preset.tags)}
-
-
-
-
-
- ${cardColorButton(preset.id, 'data-scene-id')}
-
-
`;
+ // ── Badge: SCN · XX (last 2 hex chars of id, mirrors AUTO · 07 in
+ // automations.ts and SCN · 04 in cards-redesign-demo-v2). ──
+ const shortId = (preset.id || '').replace(/^scn_/i, '').slice(-2).toUpperCase() || 'NA';
+
+ // ── Meta line: target count + last-updated timestamp. The "used by
+ // N automations" hint moves down into a chip so it reads as a
+ // crosslink, not a count. ──
+ const metaParts: string[] = [];
+ if (targetCount > 0) metaParts.push(`${targetCount} ${t('scenes.targets_count')}`);
+ if (updated) metaParts.push(updated);
+ const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
+
+ // ── Chips: usage crosslink + target count quick-jump. ──
+ const chips: ModChipOpts[] = [];
+ if (usedByCount > 0) {
+ chips.push({
+ icon: ICON_LINK,
+ text: t('scene_preset.used_by').replace('%d', String(usedByCount)),
+ variant: 'tag',
+ });
+ }
+
+ // ── 2 dim LEDs in the bezel — scenes are stored snapshots, never
+ // "running"; the two-LED cluster mirrors the demo. ──
+ const leds: LedState[] = ['off', 'off'];
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: `SCN · ${shortId}` },
+ name: preset.name,
+ metaHtml,
+ leds,
+ menu: {
+ duplicateOnclick: `cloneScenePreset('${preset.id}')`,
+ hideOnclick: `toggleCardHidden('scenes','${preset.id}')`,
+ deleteOnclick: `deleteScenePreset('${preset.id}')`,
+ },
+ },
+ body: {
+ desc: preset.description || undefined,
+ chips: chips.length ? chips : undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: t('scenes.status.preset'),
+ primaryAction: {
+ label: t('scenes.action.activate'),
+ icon: ICON_START,
+ onclick: `activateScenePreset('${preset.id}')`,
+ title: t('scenes.activate'),
+ variant: 'go',
+ },
+ secondaryActions: [{
+ label: t('scenes.action.recapture'),
+ icon: ICON_REFRESH,
+ onclick: `recaptureScenePreset('${preset.id}')`,
+ title: t('scenes.recapture'),
+ }],
+ iconActions: [{
+ icon: ICON_EDIT,
+ onclick: `editScenePreset('${preset.id}')`,
+ title: t('scenes.edit'),
+ }],
+ },
+ };
+
+ const cardHtml = wrapCard({
+ dataAttr: 'data-scene-id',
+ id: preset.id,
+ mod,
+ });
+ const tagsHtml = renderTagChips(preset.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml} `) : cardHtml;
}
// ===== Dashboard section (compact cards) =====
@@ -510,7 +557,7 @@ export function initScenePresetDelegation(container: HTMLElement): void {
if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button
if ((e.target as HTMLElement).closest('button')) return;
- navigateToCard('automations', null, 'scenes', 'data-scene-id', id!);
+ navigateToCard('automations', 'scenes', 'scenes', 'data-scene-id', id!);
return;
}
diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts
index b2b8201..b345e8d 100644
--- a/server/src/ledgrab/static/js/features/settings.ts
+++ b/server/src/ledgrab/static/js/features/settings.ts
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
-import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE, ICON_BELL, ICON_MONITOR, ICON_X, ICON_LIGHTBULB } from '../core/icons.ts';
+import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts';
import { IconSelect } from '../core/icon-select.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
import {
@@ -19,6 +19,7 @@ import {
// ─── External URL (used by other modules for user-visible URLs) ──
let _externalUrl = '';
+let _externalUrlInputBound = false;
/** Get the configured external base URL (empty string = not set). */
export function getExternalUrl(): string {
@@ -33,6 +34,24 @@ export function getBaseOrigin(): string {
return _externalUrl || window.location.origin;
}
+/** Show or hide the save-bar that sits below the External URL field. */
+function _updateExternalUrlSaveBar(): void {
+ const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
+ const bar = document.getElementById('settings-external-url-save-bar');
+ if (!input || !bar) return;
+ const dirty = input.value.trim().replace(/\/+$/, '') !== _externalUrl;
+ bar.hidden = !dirty;
+}
+
+/** Wire input listener once so editing the External URL toggles the save-bar. */
+function _bindExternalUrlInput(): void {
+ if (_externalUrlInputBound) return;
+ const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
+ if (!input) return;
+ input.addEventListener('input', _updateExternalUrlSaveBar);
+ _externalUrlInputBound = true;
+}
+
export async function loadExternalUrl(): Promise
{
try {
const resp = await fetchWithAuth('/system/external-url');
@@ -41,6 +60,8 @@ export async function loadExternalUrl(): Promise {
_externalUrl = data.external_url || '';
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
if (input) input.value = _externalUrl;
+ _bindExternalUrlInput();
+ _updateExternalUrlSaveBar();
} catch (err) {
console.error('Failed to load external URL:', err);
}
@@ -62,6 +83,7 @@ export async function saveExternalUrl(): Promise {
const data = await resp.json();
_externalUrl = data.external_url || '';
input.value = _externalUrl;
+ _updateExternalUrlSaveBar();
showToast(t('settings.external_url.saved'), 'success');
} catch (err) {
console.error('Failed to save external URL:', err);
@@ -69,13 +91,23 @@ export async function saveExternalUrl(): Promise {
}
}
+/** Discard pending edits and restore the persisted External URL value. */
+export function revertExternalUrl(): void {
+ const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
+ if (!input) return;
+ input.value = _externalUrl;
+ _updateExternalUrlSaveBar();
+}
+
// ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void {
let activeBtn: HTMLElement | null = null;
- document.querySelectorAll('.settings-tab-btn').forEach(btn => {
+ // Both selectors are queried so older cached templates with the legacy
+ // top tab strip continue to work alongside the new left rail.
+ document.querySelectorAll('.settings-tab-btn, .settings-rail-btn').forEach(btn => {
const isActive = (btn as HTMLElement).dataset.settingsTab === tabId;
btn.classList.toggle('active', isActive);
if (isActive) activeBtn = btn as HTMLElement;
@@ -83,10 +115,18 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
- // Keep the active tab visible inside the (possibly scrolling) tab bar.
+ // Keep the active tab visible inside the (possibly scrolling) tab bar / rail.
if (activeBtn) {
(activeBtn as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
+ // Swap the modal channel stripe color to match the tab's rail accent.
+ const modalContent = document.querySelector('#settings-modal .modal-content') as HTMLElement | null;
+ const railCh = (activeBtn as HTMLElement | null)?.dataset.railCh;
+ if (modalContent && railCh) {
+ modalContent.style.setProperty('--modal-ch', `var(--ch-${railCh}, var(--ch-amber))`);
+ } else if (modalContent) {
+ modalContent.style.removeProperty('--modal-ch');
+ }
// 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
@@ -113,6 +153,13 @@ export function switchSettingsTab(tabId: string): void {
/** @type {WebSocket|null} */
let _logWs: WebSocket | null = null;
+/** Connection state — drives LED cluster, patch indicator, and the
+ * signal-flow strip on the console surface. */
+type LogConnectionState = 'idle' | 'connecting' | 'live' | 'error';
+
+/** Live tally of streamed lines, by severity. Reset on Clear. */
+const _logStats = { total: 0, warn: 0, err: 0 };
+
/** Level ordering for filter comparisons */
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
@@ -142,17 +189,101 @@ function _linePassesFilter(line: string): boolean {
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
}
+/** Mirror the live tally into the .mod-metric cells and toggle the
+ * channel-tinted "has-warn"/"has-errors" classes for non-zero counts. */
+function _renderLogStats(): void {
+ const total = document.getElementById('log-stat-total');
+ const warn = document.getElementById('log-stat-warn');
+ const err = document.getElementById('log-stat-err');
+ if (total) total.textContent = String(_logStats.total);
+ if (warn) warn.textContent = String(_logStats.warn);
+ if (err) err.textContent = String(_logStats.err);
+
+ document.getElementById('log-stat-warn-cell')?.classList.toggle('has-warn', _logStats.warn > 0);
+ document.getElementById('log-stat-err-cell')?.classList.toggle('has-errors', _logStats.err > 0);
+}
+
+function _resetLogStats(): void {
+ _logStats.total = 0;
+ _logStats.warn = 0;
+ _logStats.err = 0;
+ _renderLogStats();
+}
+
+/** Map the live connection state onto the header LED cluster, the
+ * patch indicator dot/label, and the signal-flow class. */
+function _setLogConnectionState(state: LogConnectionState): void {
+ const overlay = document.getElementById('log-overlay');
+ overlay?.classList.toggle('is-streaming', state === 'live');
+
+ const leds = Array.from(document.querySelectorAll('#log-viewer-leds .led'));
+ leds.forEach((led, idx) => {
+ led.classList.remove('on', 'blink', 'fault');
+ if (state === 'live') {
+ led.classList.add('on');
+ if (idx > 0) led.classList.add('blink');
+ } else if (state === 'connecting') {
+ if (idx === 0) led.classList.add('on', 'blink');
+ } else if (state === 'error') {
+ if (idx === 0) led.classList.add('fault');
+ }
+ });
+
+ const patchDot = document.querySelector('#log-patch-indicator .patch-dot');
+ patchDot?.classList.toggle('is-live', state === 'live');
+
+ const patchLabel = document.getElementById('log-patch-label');
+ if (patchLabel) {
+ const key =
+ state === 'live' ? 'settings.logs.patch.live'
+ : state === 'connecting' ? 'settings.logs.patch.connecting'
+ : state === 'error' ? 'settings.logs.patch.error'
+ : 'settings.logs.patch.idle';
+ const fallback =
+ state === 'live' ? 'STREAMING'
+ : state === 'connecting' ? 'CONNECTING'
+ : state === 'error' ? 'OFFLINE'
+ : 'STANDBY';
+ const translated = t(key);
+ // t() returns the key itself when a translation is missing —
+ // detect that and fall through to the English label.
+ patchLabel.textContent = translated === key ? fallback : translated;
+ patchLabel.dataset.i18n = key;
+ }
+
+ const btn = document.getElementById('log-viewer-connect-btn');
+ if (btn) {
+ const isConnected = state === 'live' || state === 'connecting';
+ const labelKey = isConnected ? 'settings.logs.disconnect' : 'settings.logs.connect';
+ const labelEl = btn.querySelector('span') ?? btn;
+ labelEl.textContent = t(labelKey);
+ if (labelEl instanceof HTMLElement) labelEl.dataset.i18n = labelKey;
+ btn.classList.toggle('mod-btn-go', !isConnected);
+ btn.classList.toggle('mod-btn-stop', isConnected);
+ }
+}
+
function _appendLine(line: string): void {
// Skip keepalive empty pings
if (!line) return;
+
+ const level = _detectLevel(line);
+
+ // Tally is independent of the active filter — counts always reflect
+ // the unfiltered stream so the user can see the full picture.
+ _logStats.total += 1;
+ if (level === 'WARNING') _logStats.warn += 1;
+ if (level === 'ERROR' || level === 'CRITICAL') _logStats.err += 1;
+ _renderLogStats();
+
+ document.getElementById('log-overlay')?.classList.add('has-data');
+
if (!_linePassesFilter(line)) return;
const output = document.getElementById('log-viewer-output');
if (!output) return;
- const level = _detectLevel(line);
const cls = _levelClass(level);
-
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = line + '\n';
@@ -163,22 +294,22 @@ function _appendLine(line: string): void {
}
export function connectLogViewer(): void {
- const btn = document.getElementById('log-viewer-connect-btn');
-
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
// Disconnect
_logWs.close();
_logWs = null;
- if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
+ _setLogConnectionState('idle');
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/v1/system/logs/ws`;
+ _setLogConnectionState('connecting');
+
openAuthedWs(url).then((ws) => {
_logWs = ws;
- if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; }
+ _setLogConnectionState('live');
ws.onmessage = (evt) => {
_appendLine(evt.data);
@@ -186,14 +317,16 @@ export function connectLogViewer(): void {
ws.onerror = () => {
showToast(t('settings.logs.error'), 'error');
+ _setLogConnectionState('error');
};
ws.onclose = () => {
_logWs = null;
- if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
+ _setLogConnectionState('idle');
};
}).catch(() => {
showToast(t('settings.logs.error'), 'error');
+ _setLogConnectionState('error');
});
}
@@ -202,13 +335,14 @@ export function disconnectLogViewer(): void {
_logWs.close();
_logWs = null;
}
- const btn = document.getElementById('log-viewer-connect-btn');
- if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
+ _setLogConnectionState('idle');
}
export function clearLogViewer(): void {
const output = document.getElementById('log-viewer-output');
if (output) output.innerHTML = '';
+ _resetLogStats();
+ document.getElementById('log-overlay')?.classList.remove('has-data');
}
/** Re-render the log output according to the current filter selection. */
@@ -278,10 +412,8 @@ let _logLevelIconSelect: IconSelect | null = null;
let _autoBackupIntervalIconSelect: IconSelect | null = null;
let _shutdownActionIconSelect: IconSelect | null = null;
-// Notification matrix: one IconSelect per event type. Constructed lazily
-// when the Notifications tab is first opened so the icon palette and i18n
-// strings have a chance to load.
-const _notifIconSelects: Partial> = {};
+// Notifications: the visual matrix is now the source of truth — see
+// initNotificationsPanel() / _setNotifMatrixSelection() below.
type ShutdownAction = 'stop_targets' | 'nothing';
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
@@ -359,7 +491,8 @@ export function openSettingsModal(): void {
}
}
- // Initialize auto-backup interval icon select
+ // Initialize auto-backup interval icon select. The onChange callback
+ // auto-persists the section — there is no longer a manual Save button.
if (!_autoBackupIntervalIconSelect) {
const sel = document.getElementById('auto-backup-interval') as HTMLSelectElement | null;
if (sel) {
@@ -367,6 +500,7 @@ export function openSettingsModal(): void {
target: sel,
items: _getHourIntervalItems(),
columns: 3,
+ onChange: () => saveAutoBackupSettings(),
});
}
}
@@ -387,9 +521,26 @@ export function openSettingsModal(): void {
loadApiKeysList();
loadExternalUrl();
loadAutoBackupSettings();
+ _bindAutoBackupListeners();
loadBackupList();
loadLogLevel();
loadShutdownAction();
+ _seedRailFooter();
+ // Refresh the update status so the rail badge ("update available" pill
+ // on the Updates tab) is current when the modal opens — it would
+ // otherwise reflect whatever state the app loaded with.
+ if (typeof (window as any).loadUpdateStatus === 'function') {
+ (window as any).loadUpdateStatus();
+ }
+}
+
+/** Populate the rail footer with version info, mirroring the page header
+ * badge so the modal feels grounded. Idempotent — safe to call repeatedly. */
+function _seedRailFooter(): void {
+ const footer = document.getElementById('settings-rail-build');
+ if (!footer) return;
+ const version = document.getElementById('version-number')?.textContent?.trim() || '';
+ footer.textContent = version ? version : '';
}
export function closeSettingsModal(): void {
@@ -500,15 +651,36 @@ export async function loadAutoBackupSettings(): Promise {
} else {
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never');
}
+
+ const pill = document.getElementById('auto-backup-status-pill');
+ if (pill) {
+ if (data.enabled) {
+ pill.textContent = t('settings.auto_backup.pill.running');
+ pill.hidden = false;
+ } else {
+ pill.hidden = true;
+ }
+ }
} catch (err) {
console.error('Failed to load auto-backup settings:', err);
}
}
+/** Persist auto-backup settings. The Save button has been removed —
+ * this is invoked silently from change-listeners on the three fields
+ * (enabled checkbox, interval IconSelect, max-backups input).
+ * Errors still surface as toasts; success is silent because the
+ * Auto-Backup section's own pill/status text reflects the new state. */
export async function saveAutoBackupSettings(): Promise {
- const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked;
- const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value);
- const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).value, 10);
+ const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null;
+ const intervalEl = document.getElementById('auto-backup-interval') as HTMLInputElement | null;
+ const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null;
+ if (!enabledEl || !intervalEl || !maxEl) return;
+
+ const enabled = enabledEl.checked;
+ const interval_hours = parseFloat(intervalEl.value);
+ const max_backups = parseInt(maxEl.value, 10);
+ if (Number.isNaN(interval_hours) || Number.isNaN(max_backups)) return;
try {
const resp = await fetchWithAuth('/system/auto-backup/settings', {
@@ -519,7 +691,6 @@ export async function saveAutoBackupSettings(): Promise {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
- showToast(t('settings.auto_backup.saved'), 'success');
loadAutoBackupSettings();
loadBackupList();
} catch (err) {
@@ -528,6 +699,22 @@ export async function saveAutoBackupSettings(): Promise {
}
}
+/** Wire change listeners on the three auto-backup fields so any edit
+ * auto-saves. Called once per modal open; idempotent. */
+let _autoBackupListenersBound = false;
+function _bindAutoBackupListeners(): void {
+ if (_autoBackupListenersBound) return;
+ const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null;
+ const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null;
+ if (!enabledEl || !maxEl) return;
+ // Toggle: persist on every flip.
+ enabledEl.addEventListener('change', () => { saveAutoBackupSettings(); });
+ // Number input: fires on blur / Enter — avoids saving on every keystroke
+ // while the user is still typing a multi-digit value.
+ maxEl.addEventListener('change', () => { saveAutoBackupSettings(); });
+ _autoBackupListenersBound = true;
+}
+
export async function triggerBackupNow(): Promise {
try {
const resp = await fetchWithAuth('/system/auto-backup/trigger', { method: 'POST' });
@@ -548,40 +735,50 @@ export async function triggerBackupNow(): Promise {
export async function loadBackupList(): Promise {
const container = document.getElementById('saved-backups-list')!;
+ const meta = document.getElementById('saved-backups-meta');
+ container.setAttribute('data-empty', t('settings.saved_backups.empty'));
try {
const resp = await fetchWithAuth('/system/backups');
if (!resp.ok) return;
const data = await resp.json();
if (data.count === 0) {
- container.innerHTML = `${t('settings.saved_backups.empty')}
`;
+ container.innerHTML = '';
+ if (meta) meta.hidden = true;
return;
}
+ let totalBytes = 0;
container.innerHTML = data.backups.map(b => {
const sizeBytes = b.size_bytes || 0;
+ totalBytes += sizeBytes;
const sizeStr = sizeBytes >= 1024 * 1024
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
: (sizeBytes / 1024).toFixed(1) + ' KB';
const date = new Date(b.created_at).toLocaleString();
const isAuto = b.filename.startsWith('ledgrab-autobackup-');
- const typeBadge = isAuto
- ? `${t('settings.saved_backups.type.auto')}`
- : `${t('settings.saved_backups.type.manual')}`;
- return `
- ${typeBadge}
-
-
${date}
-
${sizeStr}
+ const typeKey = isAuto ? 'settings.saved_backups.type.auto' : 'settings.saved_backups.type.manual';
+ return `
+
+
${date}
+
${sizeStr} · ${t(typeKey)}
-
-
-
+
+
+
`;
}).join('');
+ if (meta) {
+ meta.hidden = false;
+ const totalStr = totalBytes >= 1024 * 1024
+ ? (totalBytes / (1024 * 1024)).toFixed(1) + ' MB'
+ : (totalBytes / 1024).toFixed(1) + ' KB';
+ meta.textContent = `${data.count} · ${totalStr}`;
+ }
} catch (err) {
console.error('Failed to load backup list:', err);
container.innerHTML = '';
+ if (meta) meta.hidden = true;
}
}
@@ -668,26 +865,35 @@ export async function deleteSavedBackup(filename: string): Promise
{
export async function loadApiKeysList(): Promise {
const container = document.getElementById('settings-api-keys-list');
if (!container) return;
+ const meta = document.getElementById('settings-api-keys-meta');
try {
const resp = await fetchWithAuth('/system/api-keys');
if (!resp.ok) {
- container.innerHTML = `${t('settings.api_keys.load_error')}
`;
+ container.innerHTML = `${t('settings.api_keys.load_error')}
`;
+ if (meta) meta.hidden = true;
return;
}
const data = await resp.json();
if (data.count === 0) {
- container.innerHTML = `${t('settings.api_keys.empty')}
`;
+ container.innerHTML = `${t('settings.api_keys.empty')}
`;
+ if (meta) meta.hidden = true;
return;
}
container.innerHTML = data.keys.map(k =>
- `
-
${k.label}
-
${k.masked}
+ `
+ ${k.label}
+ ${k.masked}
+ ${t('settings.api_keys.read_only')}
`
).join('');
+ if (meta) {
+ meta.hidden = false;
+ meta.textContent = `${data.count} ${data.count === 1 ? t('settings.api_keys.meta.one') : t('settings.api_keys.meta.many')}`;
+ }
} catch (err) {
console.error('Failed to load API keys:', err);
if (container) container.innerHTML = '';
+ if (meta) meta.hidden = true;
}
}
@@ -776,33 +982,88 @@ const _NOTIF_EVENT_KEYS = [
] as const;
type NotifEventKey = typeof _NOTIF_EVENT_KEYS[number];
-function _getNotifChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
- return [
- { value: 'none', icon: ICON_X, label: t('settings.notifications.channel.none.label'), desc: t('settings.notifications.channel.none.desc') },
- { value: 'snack', icon: ICON_BELL, label: t('settings.notifications.channel.snack.label'), desc: t('settings.notifications.channel.snack.desc') },
- { value: 'os', icon: ICON_MONITOR, label: t('settings.notifications.channel.os.label'), desc: t('settings.notifications.channel.os.desc') },
- { value: 'both', icon: ICON_LIGHTBULB, label: t('settings.notifications.channel.both.label'), desc: t('settings.notifications.channel.both.desc') },
- ];
-}
-
function _isNotifChannel(v: string): v is NotificationChannel {
return v === 'none' || v === 'snack' || v === 'os' || v === 'both';
}
let _notifPrefsLoaded = false;
+let _notifMatrixWired = false;
+
+const _NOTIF_CHANNELS: NotificationChannel[] = ['snack', 'os', 'both', 'none'];
+
+/** Reflect a (event, channel) selection in the visual matrix and the
+ * hidden underlying
`) : cardHtml;
};
const renderCaptureTemplateCard = (template: any) => {
- const engineIcon = getEngineIcon(template.engine_type);
- const configEntries = Object.entries(template.engine_config);
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-template-id',
- id: template.id,
- removeOnclick: `deleteTemplate('${template.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${template.description ? `${escapeHtml(template.description)}
` : ''}
-
- ${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}
- ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
-
- ${renderTagChips(template.tags)}
- ${configEntries.length > 0 ? `
-
-
-
-
-
- ${configEntries.map(([key, val]) => `
-
- | ${escapeHtml(key)} |
- ${escapeHtml(String(val))} |
-
- `).join('')}
-
-
-
+ const configEntries = Object.entries(template.engine_config || {});
+ const chips: ModChipOpts[] = [
+ { icon: getEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('templates.engine') },
+ ];
+ if (configEntries.length > 0) {
+ chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('templates.config.show') || 'config')}`, title: t('templates.config.show') });
+ }
+ const configBlock = configEntries.length > 0 ? `
+
+
+
+
+
+ ${configEntries.map(([key, val]) => `
+
+ | ${escapeHtml(key)} |
+ ${escapeHtml(String(val))} |
+
+ `).join('')}
+
- ` : ''}`,
- actions: `
-
-
-
`,
- });
+
+
` : '';
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'TPL · CAPTURE' },
+ name: template.name,
+ metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `cloneCaptureTemplate('${template.id}')`,
+ hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`,
+ deleteOnclick: `deleteTemplate('${template.id}')`,
+ },
+ },
+ body: {
+ desc: template.description || undefined,
+ chips,
+ extraHtml: configBlock || undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'TEMPLATE',
+ iconActions: [
+ { icon: ICON_TEST, onclick: `showTestTemplateModal('${template.id}')`, title: t('templates.test.title') },
+ { icon: ICON_EDIT, onclick: `editTemplate('${template.id}')`, title: t('common.edit') },
+ ],
+ },
+ };
+
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-template-id', id: template.id, mod });
+ const tagsHtml = renderTagChips(template.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
`) : cardHtml;
};
const renderPPTemplateCard = (tmpl: any) => {
- let filterChainHtml = '';
- if (tmpl.filters && tmpl.filters.length > 0) {
- const filterNames = tmpl.filters.map(fi => {
+ const filters = tmpl.filters || [];
+ const chainExtra = filters.length > 0 ? `${
+ filters.map((fi: any, idx: number) => {
let label = _getFilterName(fi.filter_id);
if (fi.filter_id === 'filter_template' && fi.options?.template_id) {
const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
- return `
${escapeHtml(label)}`;
- });
- filterChainHtml = `
${filterNames.join('→')}
`;
- }
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-pp-template-id',
- id: tmpl.id,
- removeOnclick: `deletePPTemplate('${tmpl.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
- ${filterChainHtml}
- ${renderTagChips(tmpl.tags)}`,
- actions: `
-
-
-
`,
- });
+ const arrow = idx < filters.length - 1 ? '
→' : '';
+ return `
${escapeHtml(label)}${arrow}`;
+ }).join('')
+ }
` : '';
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'TPL · FILTER' },
+ name: tmpl.name,
+ metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
+ hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
+ deleteOnclick: `deletePPTemplate('${tmpl.id}')`,
+ },
+ },
+ body: {
+ desc: tmpl.description || undefined,
+ extraHtml: chainExtra || undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'PIPELINE',
+ iconActions: [
+ { icon: ICON_TEST, onclick: `showTestPPTemplateModal('${tmpl.id}')`, title: t('postprocessing.test.title') },
+ { icon: ICON_EDIT, onclick: `editPPTemplate('${tmpl.id}')`, title: t('common.edit') },
+ ],
+ },
+ };
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-pp-template-id', id: tmpl.id, mod });
+ const tagsHtml = renderTagChips(tmpl.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml;
};
const renderCSPTCard = (tmpl: any) => {
- let filterChainHtml = '';
- if (tmpl.filters && tmpl.filters.length > 0) {
- const filterNames = tmpl.filters.map(fi => {
+ const filters = tmpl.filters || [];
+ const chainExtra = filters.length > 0 ? `
${
+ filters.map((fi: any, idx: number) => {
let label = _getStripFilterName(fi.filter_id);
if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) {
const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
- return `
${escapeHtml(label)}`;
- });
- filterChainHtml = `
${filterNames.join('\u2192')}
`;
- }
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-cspt-id',
- id: tmpl.id,
- removeOnclick: `deleteCSPT('${tmpl.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
- ${filterChainHtml}
- ${renderTagChips(tmpl.tags)}`,
- actions: `
-
-
-
`,
- });
+ const arrow = idx < filters.length - 1 ? '
\u2192' : '';
+ return `
${escapeHtml(label)}${arrow}`;
+ }).join('')
+ }
` : '';
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'TPL \u00b7 STRIP' },
+ name: tmpl.name,
+ metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
+ hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
+ deleteOnclick: `deleteCSPT('${tmpl.id}')`,
+ },
+ },
+ body: {
+ desc: tmpl.description || undefined,
+ extraHtml: chainExtra || undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'PIPELINE',
+ iconActions: [
+ { icon: ICON_TEST, onclick: `event.stopPropagation(); testCSPT('${tmpl.id}')`, title: t('color_strip.test.title') },
+ { icon: ICON_EDIT, onclick: `editCSPT('${tmpl.id}')`, title: t('common.edit') },
+ ],
+ },
+ };
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-cspt-id', id: tmpl.id, mod });
+ const tagsHtml = renderTagChips(tmpl.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
`) : cardHtml;
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
@@ -646,120 +740,178 @@ function renderPictureSourcesList(streams: any) {
};
const renderAudioSourceCard = (src: any) => {
- const icon = getAudioSourceIcon(src.source_type);
+ const chips: ModChipOpts[] = [];
+ let badgeText: string;
+ let metaText: string;
- let propsHtml = '';
if (src.source_type === 'processed') {
+ badgeText = 'AUDIO · FX';
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
- const parentName = parent ? parent.name : src.audio_source_id;
+ const parentName = parent ? parent.name : (src.audio_source_id || '—');
const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture';
const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture';
- const parentBadge = parent
- ? `${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}`
- : `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`;
- propsHtml = `${parentBadge}`;
+ chips.push({
+ icon: parent ? getAudioSourceIcon(parent.source_type) : ICON_AUDIO_LOOPBACK,
+ text: parentName,
+ title: t('audio_source.parent'),
+ onclick: parent
+ ? `event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')`
+ : undefined,
+ });
if (src.audio_processing_template_id) {
- const aptTmpl = _cachedAudioProcessingTemplates.find(t => t.id === src.audio_processing_template_id);
- const aptName = aptTmpl ? escapeHtml(aptTmpl.name) : escapeHtml(src.audio_processing_template_id);
- propsHtml += aptTmpl
- ? `${ICON_AUDIO_TEMPLATE} ${aptName}`
- : `${ICON_AUDIO_TEMPLATE} ${aptName}`;
+ const aptTmpl = _cachedAudioProcessingTemplates.find(tt => tt.id === src.audio_processing_template_id);
+ const aptName = aptTmpl ? aptTmpl.name : src.audio_processing_template_id;
+ chips.push({
+ icon: ICON_AUDIO_TEMPLATE,
+ text: aptName,
+ title: t('audio_processing.title'),
+ onclick: aptTmpl
+ ? `event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')`
+ : undefined,
+ });
}
+ metaText = parent ? `via ${parentName}` : 'orphan source';
} else {
- // Capture source
- const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
- const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
- const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
- const tplBadge = tpl ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : '';
- propsHtml = `${devLabel} #${devIdx}${tplBadge}`;
+ const devIdx = src.device_index ?? -1;
+ badgeText = loopback ? 'LOOP · IN' : 'MIC · IN';
+ const devLabel = loopback ? 'Loopback' : 'Input';
+ metaText = `${devLabel} #${devIdx}`;
+ const tpl = src.audio_template_id ? _cachedAudioTemplates.find(tt => tt.id === src.audio_template_id) : null;
+ if (tpl) {
+ chips.push({
+ icon: ICON_AUDIO_TEMPLATE,
+ text: tpl.name,
+ title: t('audio_source.audio_template'),
+ onclick: `event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')`,
+ });
+ }
}
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-id',
- id: src.id,
- removeOnclick: `deleteAudioSource('${src.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${src.description ? `${escapeHtml(src.description)}
` : ''}
- ${propsHtml}
- ${renderTagChips(src.tags)}`,
- actions: `
-
-
- `,
- });
+ const sectionKey = src.source_type === 'processed' ? 'audio-processed' : 'audio-capture';
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: badgeText },
+ name: src.name,
+ metaHtml: escapeHtml(metaText),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `cloneAudioSource('${src.id}')`,
+ hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
+ deleteOnclick: `deleteAudioSource('${src.id}')`,
+ },
+ },
+ body: {
+ desc: src.description || undefined,
+ chips: chips.length ? chips : undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'SOURCE',
+ iconActions: [
+ { icon: ICON_TEST, onclick: '', title: t('audio_source.test'), dataAttrs: { 'data-action': 'test-audio' } },
+ { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit-audio' } },
+ ],
+ },
+ };
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod });
+ const tagsHtml = renderTagChips(src.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml} `) : cardHtml;
};
const renderAudioTemplateCard = (template: any) => {
const configEntries = Object.entries(template.engine_config || {});
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-audio-template-id',
- id: template.id,
- removeOnclick: `deleteAudioTemplate('${template.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${template.description ? `
${escapeHtml(template.description)}
` : ''}
-
- ${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}
- ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
-
- ${renderTagChips(template.tags)}
- ${configEntries.length > 0 ? `
-
-
-
-
-
- ${configEntries.map(([key, val]) => `
-
- | ${escapeHtml(key)} |
- ${escapeHtml(String(val))} |
-
- `).join('')}
-
-
-
+ const chips: ModChipOpts[] = [
+ { icon: getAudioEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('audio_template.engine') },
+ ];
+ if (configEntries.length > 0) {
+ chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('audio_template.config.show') || 'config')}`, title: t('audio_template.config.show') });
+ }
+ const configBlock = configEntries.length > 0 ? `
+
+
+
+
+
+ ${configEntries.map(([key, val]) => `
+
+ | ${escapeHtml(key)} |
+ ${escapeHtml(String(val))} |
+
+ `).join('')}
+
- ` : ''}`,
- actions: `
-
-
-
`,
- });
+
+
` : '';
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'TPL · AUDIO' },
+ name: template.name,
+ metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `cloneAudioTemplate('${template.id}')`,
+ hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`,
+ deleteOnclick: `deleteAudioTemplate('${template.id}')`,
+ },
+ },
+ body: {
+ desc: template.description || undefined,
+ chips,
+ extraHtml: configBlock || undefined,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'TEMPLATE',
+ iconActions: [
+ { icon: ICON_TEST, onclick: `showTestAudioTemplateModal('${template.id}')`, title: t('audio_template.test') },
+ { icon: ICON_EDIT, onclick: `editAudioTemplate('${template.id}')`, title: t('common.edit') },
+ ],
+ },
+ };
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-audio-template-id', id: template.id, mod });
+ const tagsHtml = renderTagChips(template.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
`) : cardHtml;
};
// Gradient card renderer
const renderGradientCard = (g: GradientEntity) => {
const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
- const stripPreview = `
`;
- const lockBadge = g.is_builtin ? `
${t('gradient.builtin')}` : '';
- const cloneBtn = `
`;
- const editBtn = g.is_builtin ? '' : `
`;
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-id',
- id: g.id,
- removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
- ${stripPreview}
-
- ${g.stops.length} ${t('gradient.stops_label')}
-
`,
- actions: `${cloneBtn}${editBtn}`,
- });
+ // The `.mod-preview` wrapper inside renderModBody doesn't accept
+ // inline style, so emit a sibling block via `extraHtml` so the
+ // gradient fills the full preview surface.
+ const previewBlock = `
${
+ g.is_builtin ? `${escapeHtml(t('gradient.builtin') || 'BUILTIN').toUpperCase()}` : ''
+ }
`;
+ const iconActions: any[] = [
+ { icon: ICON_CLONE, onclick: `cloneGradient('${g.id}')`, title: t('common.clone') },
+ ];
+ if (!g.is_builtin) {
+ iconActions.push({ icon: ICON_EDIT, onclick: `editGradient('${g.id}')`, title: t('common.edit') });
+ }
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'PALETTE · GRD' },
+ name: g.name,
+ metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
+ leds: ['off'],
+ menu: {
+ duplicateOnclick: `cloneGradient('${g.id}')`,
+ hideOnclick: `toggleCardHidden('gradients','${g.id}')`,
+ deleteOnclick: g.is_builtin ? undefined : `deleteGradient('${g.id}')`,
+ },
+ },
+ body: {
+ extraHtml: previewBlock,
+ },
+ foot: {
+ patchState: 'idle',
+ patchLabel: 'PRESET',
+ iconActions,
+ },
+ };
+ return wrapCard({ type: 'template-card', dataAttr: 'data-id', id: g.id, mod });
};
// Build item arrays for all sections
diff --git a/server/src/ledgrab/static/js/features/sync-clocks.ts b/server/src/ledgrab/static/js/features/sync-clocks.ts
index 7c8973a..1074642 100644
--- a/server/src/ledgrab/static/js/features/sync-clocks.ts
+++ b/server/src/ledgrab/static/js/features/sync-clocks.ts
@@ -9,6 +9,7 @@ import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
+import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.ts';
@@ -223,35 +224,51 @@ function _formatElapsed(seconds: number): string {
}
export function createSyncClockCard(clock: SyncClock) {
- const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
- const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
- const toggleAction = clock.is_running ? 'pause' : 'resume';
- const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
+ const isRunning = !!clock.is_running;
+ const statusLabel = isRunning ? t('sync_clock.status.running') : t('sync_clock.status.paused');
+ const toggleAction = isRunning ? 'pause' : 'resume';
+ const toggleTitle = isRunning ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
- return wrapCard({
- type: 'template-card',
- dataAttr: 'data-id',
- id: clock.id,
- removeOnclick: `deleteSyncClock('${clock.id}')`,
- removeTitle: t('common.delete'),
- content: `
-
-
- ${statusIcon} ${statusLabel}
- ${ICON_CLOCK} ${clock.speed}x
- ${elapsedLabel ? `⏱ ${elapsedLabel}` : ''}
-
- ${renderTagChips(clock.tags)}
- ${clock.description ? `
${escapeHtml(clock.description)}
` : ''}`,
- actions: `
-
-
-
-
`,
- });
+ const chips: ModChipOpts[] = [
+ { icon: ICON_CLOCK, text: `${clock.speed}x` },
+ ];
+ if (elapsedLabel) {
+ chips.push({ text: `⏱ ${elapsedLabel}`, title: t('sync_clock.elapsed') });
+ }
+
+ const leds: LedState[] = isRunning ? ['on', 'blink'] : ['off'];
+
+ const mod: ModCardOpts = {
+ head: {
+ badge: { text: 'CLK · MASTER' },
+ name: clock.name,
+ metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`),
+ leds,
+ menu: {
+ duplicateOnclick: `cloneSyncClock('${clock.id}')`,
+ hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`,
+ deleteOnclick: `deleteSyncClock('${clock.id}')`,
+ },
+ },
+ body: {
+ desc: clock.description || undefined,
+ chips,
+ },
+ foot: {
+ patchState: isRunning ? 'live' : 'idle',
+ patchLabel: isRunning ? 'TICKING' : 'PAUSED',
+ iconActions: [
+ { icon: isRunning ? ICON_PAUSE : ICON_START, onclick: '', title: toggleTitle, dataAttrs: { 'data-action': toggleAction } },
+ { icon: ICON_CLOCK, onclick: '', title: t('sync_clock.action.reset'), dataAttrs: { 'data-action': 'reset' } },
+ { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
+ ],
+ },
+ running: isRunning,
+ };
+ const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: clock.id, mod });
+ const tagsHtml = renderTagChips(clock.tags);
+ return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
`) : cardHtml;
}
// ── Event delegation for sync-clock card actions ──
diff --git a/server/src/ledgrab/static/js/features/update.ts b/server/src/ledgrab/static/js/features/update.ts
index a4fe994..ffe6a4e 100644
--- a/server/src/ledgrab/static/js/features/update.ts
+++ b/server/src/ledgrab/static/js/features/update.ts
@@ -10,6 +10,12 @@ import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
// ─── State ──────────────────────────────────────────────────
+interface UpdateAsset {
+ name: string;
+ size: number;
+ download_url: string;
+}
+
interface UpdateRelease {
version: string;
tag: string;
@@ -17,6 +23,7 @@ interface UpdateRelease {
body: string;
prerelease: boolean;
published_at: string;
+ assets?: UpdateAsset[];
}
interface UpdateStatus {
@@ -179,7 +186,15 @@ function _applyStatus(status: UpdateStatus): void {
&& status.release != null
&& status.release.version !== dismissed;
+ // The header badge + floating banner respect the user's dismissal so they
+ // don't keep nagging on every page reload. The rail badge inside the
+ // settings modal is the *destination* the user is told to visit — it
+ // should keep flagging the update even after dismissal so they can find
+ // the Updates tab again. Hide it only when there's genuinely no update.
+ const hasAnyUpdate = status.has_update && status.release != null;
+
_setVersionBadgeUpdate(hasVisibleUpdate);
+ _setRailUpdateBadge(hasAnyUpdate, status.release?.version ?? '');
if (hasVisibleUpdate) {
_showBanner(status);
@@ -190,6 +205,24 @@ function _applyStatus(status: UpdateStatus): void {
_renderUpdatePanel(status);
}
+/** Toggle the small badge on the Updates rail-button so the sidebar
+ * reflects "update available" the same way the in-app version badge does.
+ * Runs on every status fetch / WS event so the indicator stays in sync. */
+function _setRailUpdateBadge(visible: boolean, version: string): void {
+ const badge = document.getElementById('settings-rail-update-badge');
+ if (!badge) return;
+ badge.hidden = !visible;
+ if (visible) {
+ // Numeric "1" reads cleaner than the version string in the small pill.
+ badge.textContent = '1';
+ if (version) {
+ badge.setAttribute('title', t('update.available').replace('{version}', version));
+ }
+ } else {
+ badge.removeAttribute('title');
+ }
+}
+
// ─── WS event handlers ─────────────────────────────────────
export function initUpdateListener(): void {
@@ -282,6 +315,8 @@ function _getChannelItems(): { value: string; icon: string; label: string; desc:
}
export function initUpdateSettingsPanel(): void {
+ // The IconSelects auto-persist on change — there is no longer a manual
+ // Save button in the Auto-Check section.
if (!_channelIconSelect) {
const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
if (sel) {
@@ -289,6 +324,7 @@ export function initUpdateSettingsPanel(): void {
target: sel,
items: _getChannelItems(),
columns: 2,
+ onChange: () => saveUpdateSettings(),
});
}
}
@@ -299,9 +335,16 @@ export function initUpdateSettingsPanel(): void {
target: sel,
items: _getIntervalItems(),
columns: 3,
+ onChange: () => saveUpdateSettings(),
});
}
}
+ // Toggle auto-saves on every flip.
+ const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
+ if (enabledEl && !enabledEl.dataset.autosaveBound) {
+ enabledEl.addEventListener('change', () => { saveUpdateSettings(); });
+ enabledEl.dataset.autosaveBound = '1';
+ }
}
export async function loadUpdateSettings(): Promise