Codebase review: stability, performance, usability, and i18n fixes

Stability:
- Fix race condition: set _is_running before create_task in target processors
- Await probe task after cancel in wled_target_processor
- Replace raw fetch() with fetchWithAuth() across devices, kc-targets, pattern-templates
- Add try/catch to showTestTemplateModal in streams.js
- Wrap blocking I/O in asyncio.to_thread (picture_targets, system restore)
- Fix dashboardStopAll to filter only running targets with ok guard

Performance:
- Vectorize fire effect spark loop with numpy in effect_stream
- Vectorize FFT band binning with cumulative sum in analysis.py
- Rewrite pixel_processor with vectorized numpy (accept ndarray or list)
- Add httpx.AsyncClient connection pooling with lock in wled_provider
- Optimize _send_pixels_http to avoid np.hstack allocation in wled_client
- Mutate chart arrays in-place in dashboard, perf-charts, targets
- Merge dashboard 2-batch fetch into single Promise.all
- Hoist frame_time outside loop in mapped_stream

Usability:
- Fix health check interval load/save in device settings
- Swap confirm modal button classes (No=secondary, Yes=danger)
- Add aria-modal to audio/value source editors, fix close button aria-labels
- Add modal footer close button to settings modal
- Add dedicated calibration LED count validation error keys

i18n:
- Replace ~50 hardcoded English strings with t() calls across 12 JS files
- Add 50 new keys to en.json, ru.json, zh.json
- Localize inline toasts in index.html with window.t fallback
- Add data-i18n to command palette footer
- Add localization policy to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:12:37 +03:00
parent c95c6e9a44
commit bd8d7a019f
31 changed files with 460 additions and 233 deletions

View File

@@ -191,6 +191,15 @@ The app has an interactive tutorial system (`static/js/features/tutorials.js`) w
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`). When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
## Localization (i18n)
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
## General Guidelines ## General Guidelines
- Always test changes before marking as complete - Always test changes before marking as complete

View File

@@ -1,5 +1,6 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics.""" """Picture target routes: CRUD, processing control, settings, state, metrics."""
import asyncio
import base64 import base64
import io import io
import secrets import secrets
@@ -297,9 +298,10 @@ async def update_target(
if "brightness_value_source_id" in kc_incoming: if "brightness_value_source_id" in kc_incoming:
kc_brightness_vs_changed = True kc_brightness_vs_changed = True
# Sync processor manager # Sync processor manager (run in thread — css release/acquire can block)
try: try:
target.sync_with_manager( await asyncio.to_thread(
target.sync_with_manager,
manager, manager,
settings_changed=(data.fps is not None or settings_changed=(data.fps is not None or
data.keepalive_interval is not None or data.keepalive_interval is not None or

View File

@@ -1,5 +1,6 @@
"""System routes: health, version, displays, performance, backup/restore, ADB.""" """System routes: health, version, displays, performance, backup/restore, ADB."""
import asyncio
import io import io
import json import json
import platform import platform
@@ -372,15 +373,20 @@ async def restore_config(
if not isinstance(stores[key], dict): if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object") raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Write store files atomically # Write store files atomically (in thread to avoid blocking event loop)
config = get_config() config = get_config()
written = 0
for store_key, config_attr in STORE_MAP.items(): def _write_stores():
if store_key in stores: count = 0
file_path = Path(getattr(config.storage, config_attr)) for store_key, config_attr in STORE_MAP.items():
atomic_write_json(file_path, stores[store_key]) if store_key in stores:
written += 1 file_path = Path(getattr(config.storage, config_attr))
logger.info(f"Restored store: {store_key} -> {file_path}") atomic_write_json(file_path, stores[store_key])
count += 1
logger.info(f"Restored store: {store_key} -> {file_path}")
return count
written = await asyncio.to_thread(_write_stores)
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...") logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart() _schedule_restart()

View File

@@ -99,6 +99,12 @@ class AudioAnalyzer:
self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32) self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32)
self._sq_buf = np.empty(chunk_size, dtype=np.float32) self._sq_buf = np.empty(chunk_size, dtype=np.float32)
# Pre-compute band start/end arrays and widths for vectorized binning
self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp)
self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp)
self._band_widths = (self._band_ends - self._band_starts).astype(np.float32)
self._band_widths[self._band_widths == 0] = 1.0 # avoid divide-by-zero
# Pre-allocated channel buffers for stereo # Pre-allocated channel buffers for stereo
self._left_buf = np.empty(chunk_size, dtype=np.float32) self._left_buf = np.empty(chunk_size, dtype=np.float32)
self._right_buf = np.empty(chunk_size, dtype=np.float32) self._right_buf = np.empty(chunk_size, dtype=np.float32)
@@ -205,11 +211,15 @@ class AudioAnalyzer:
fft_mag = np.abs(np.fft.rfft(self._fft_windowed)) fft_mag = np.abs(np.fft.rfft(self._fft_windowed))
fft_mag *= (1.0 / chunk_size) fft_mag *= (1.0 / chunk_size)
fft_len = len(fft_mag) fft_len = len(fft_mag)
for b, (s, e) in enumerate(self._bands): # Vectorized band binning using cumulative sum
if s < fft_len and e <= fft_len: valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
buf[b] = float(np.mean(fft_mag[s:e])) buf[:] = 0.0
else: if valid.any():
buf[b] = 0.0 cumsum = np.cumsum(fft_mag)
band_sums = cumsum[self._band_ends[valid] - 1] - np.where(
self._band_starts[valid] > 0, cumsum[self._band_starts[valid] - 1], 0.0
)
buf[valid] = band_sums / self._band_widths[valid]
spec_max = float(np.max(buf)) spec_max = float(np.max(buf))
if spec_max > 1e-6: if spec_max > 1e-6:
buf *= (1.0 / spec_max) buf *= (1.0 / spec_max)

View File

@@ -1,18 +1,27 @@
"""Pixel processing utilities for color correction and manipulation.""" """Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple from typing import List, Tuple, Union
import numpy as np import numpy as np
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
def _as_array(colors: ColorList) -> np.ndarray:
"""Convert list-of-tuples to (N,3) uint8 array, or pass through if already ndarray."""
if isinstance(colors, np.ndarray):
return colors
return np.array(colors, dtype=np.uint8)
def smooth_colors( def smooth_colors(
current_colors: List[Tuple[int, int, int]], current_colors: ColorList,
previous_colors: List[Tuple[int, int, int]], previous_colors: ColorList,
smoothing_factor: float = 0.5, smoothing_factor: float = 0.5,
) -> List[Tuple[int, int, int]]: ) -> np.ndarray:
"""Smooth color transitions between frames. """Smooth color transitions between frames.
Args: Args:
@@ -21,96 +30,71 @@ def smooth_colors(
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing) smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
Returns: Returns:
Smoothed colors Smoothed colors as (N,3) uint8 ndarray
""" """
if not current_colors or not previous_colors: if not len(current_colors) or not len(previous_colors):
return current_colors return _as_array(current_colors)
if len(current_colors) != len(previous_colors): if len(current_colors) != len(previous_colors):
logger.warning( logger.warning(
f"Color count mismatch: current={len(current_colors)}, " f"Color count mismatch: current={len(current_colors)}, "
f"previous={len(previous_colors)}. Skipping smoothing." f"previous={len(previous_colors)}. Skipping smoothing."
) )
return current_colors return _as_array(current_colors)
if smoothing_factor <= 0: if smoothing_factor <= 0:
return current_colors return _as_array(current_colors)
if smoothing_factor >= 1: if smoothing_factor >= 1:
return previous_colors return _as_array(previous_colors)
# Convert to numpy arrays current = np.asarray(current_colors, dtype=np.float32)
current = np.array(current_colors, dtype=np.float32) previous = np.asarray(previous_colors, dtype=np.float32)
previous = np.array(previous_colors, dtype=np.float32)
# Blend between current and previous
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
return np.clip(smoothed, 0, 255).astype(np.uint8)
# Convert back to integers
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
return [tuple(color) for color in smoothed]
def adjust_brightness_global( def adjust_brightness_global(
colors: List[Tuple[int, int, int]], colors: ColorList,
target_brightness: int, target_brightness: int,
) -> List[Tuple[int, int, int]]: ) -> np.ndarray:
"""Adjust colors to achieve target global brightness. """Adjust colors to achieve target global brightness.
Args: Args:
colors: List of (R, G, B) tuples colors: List of (R, G, B) tuples or (N,3) ndarray
target_brightness: Target brightness (0-255) target_brightness: Target brightness (0-255)
Returns: Returns:
Adjusted colors Adjusted colors as (N,3) uint8 ndarray
""" """
if not colors or target_brightness == 255: arr = _as_array(colors)
return colors if not len(arr) or target_brightness == 255:
return arr
# Calculate scaling factor
scale = target_brightness / 255.0 scale = target_brightness / 255.0
return (arr.astype(np.float32) * scale).astype(np.uint8)
# Scale all colors
scaled = [
(
int(r * scale),
int(g * scale),
int(b * scale),
)
for r, g, b in colors
]
return scaled
def limit_brightness( def limit_brightness(
colors: List[Tuple[int, int, int]], colors: ColorList,
max_brightness: int = 255, max_brightness: int = 255,
) -> List[Tuple[int, int, int]]: ) -> np.ndarray:
"""Limit maximum brightness of any color channel. """Limit maximum brightness of any color channel.
Args: Args:
colors: List of (R, G, B) tuples colors: List of (R, G, B) tuples or (N,3) ndarray
max_brightness: Maximum allowed brightness (0-255) max_brightness: Maximum allowed brightness (0-255)
Returns: Returns:
Limited colors Limited colors as (N,3) uint8 ndarray
""" """
if not colors or max_brightness == 255: arr = _as_array(colors)
return colors if not len(arr) or max_brightness == 255:
return arr
limited = [] arr_f = arr.astype(np.float32)
for r, g, b in colors: max_vals = np.max(arr_f, axis=1)
# Find max channel value need_scale = max_vals > max_brightness
max_val = max(r, g, b) if need_scale.any():
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
if max_val > max_brightness: arr_f *= scales[:, np.newaxis]
# Scale down proportionally return arr_f.astype(np.uint8)
scale = max_brightness / max_val
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
limited.append((r, g, b))
return limited

View File

@@ -404,8 +404,13 @@ class WLEDClient(LEDClient):
""" """
try: try:
# Build indexed pixel array: [led_index, r, g, b, ...] # Build indexed pixel array: [led_index, r, g, b, ...]
indices = np.arange(len(pixels), dtype=np.int32).reshape(-1, 1) n = len(pixels)
indexed_pixels = np.hstack([indices, pixels.astype(np.int32)]).ravel().tolist() flat = np.empty(n * 4, dtype=np.int32)
flat[0::4] = np.arange(n, dtype=np.int32)
flat[1::4] = pixels[:, 0]
flat[2::4] = pixels[:, 1]
flat[3::4] = pixels[:, 2]
indexed_pixels = flat.tolist()
# Build WLED JSON state # Build WLED JSON state
payload = { payload = {

View File

@@ -24,6 +24,25 @@ DEFAULT_SCAN_TIMEOUT = 3.0
class WLEDDeviceProvider(LEDDeviceProvider): class WLEDDeviceProvider(LEDDeviceProvider):
"""Provider for WLED LED controllers.""" """Provider for WLED LED controllers."""
def __init__(self):
self._http_client: Optional[httpx.AsyncClient] = None
self._client_lock = asyncio.Lock()
async def _get_client(self) -> httpx.AsyncClient:
"""Return a shared HTTP client (connection-pooled, thread-safe init)."""
if self._http_client is not None and not self._http_client.is_closed:
return self._http_client
async with self._client_lock:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=5.0)
return self._http_client
async def close(self):
"""Close the shared HTTP client."""
if self._http_client is not None and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
@property @property
def device_type(self) -> str: def device_type(self) -> str:
return "wled" return "wled"
@@ -158,46 +177,46 @@ class WLEDDeviceProvider(LEDDeviceProvider):
async def get_brightness(self, url: str) -> int: async def get_brightness(self, url: str) -> int:
url = url.rstrip("/") url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client: client = await self._get_client()
resp = await http_client.get(f"{url}/json/state") resp = await client.get(f"{url}/json/state")
resp.raise_for_status() resp.raise_for_status()
state = resp.json() state = resp.json()
return state.get("bri", 255) return state.get("bri", 255)
async def set_brightness(self, url: str, brightness: int) -> None: async def set_brightness(self, url: str, brightness: int) -> None:
url = url.rstrip("/") url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client: client = await self._get_client()
resp = await http_client.post( resp = await client.post(
f"{url}/json/state", f"{url}/json/state",
json={"bri": brightness}, json={"bri": brightness},
) )
resp.raise_for_status() resp.raise_for_status()
async def get_power(self, url: str, **kwargs) -> bool: async def get_power(self, url: str, **kwargs) -> bool:
url = url.rstrip("/") url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client: client = await self._get_client()
resp = await http_client.get(f"{url}/json/state") resp = await client.get(f"{url}/json/state")
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("on", False) return resp.json().get("on", False)
async def set_power(self, url: str, on: bool, **kwargs) -> None: async def set_power(self, url: str, on: bool, **kwargs) -> None:
url = url.rstrip("/") url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client: client = await self._get_client()
resp = await http_client.post( resp = await client.post(
f"{url}/json/state", f"{url}/json/state",
json={"on": on}, json={"on": on},
) )
resp.raise_for_status() resp.raise_for_status()
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Set WLED to a solid color using the native segment color API.""" """Set WLED to a solid color using the native segment color API."""
url = url.rstrip("/") url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client: client = await self._get_client()
resp = await http_client.post( resp = await client.post(
f"{url}/json/state", f"{url}/json/state",
json={ json={
"on": True, "on": True,
"seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}], "seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}],
}, },
) )
resp.raise_for_status() resp.raise_for_status()

View File

@@ -356,12 +356,17 @@ class EffectColorStripStream(ColorStripStream):
new_heat[-1] = heat[-1] * 0.5 new_heat[-1] = heat[-1] * 0.5
heat[:] = new_heat heat[:] = new_heat
# Sparks at the bottom # Sparks at the bottom (vectorized)
spark_zone = max(1, n // 8) spark_zone = max(1, n // 8)
spark_prob = 0.3 * intensity spark_prob = 0.3 * intensity
for i in range(spark_zone): rng = np.random.random(spark_zone)
if np.random.random() < spark_prob: mask = rng < spark_prob
heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random()) if mask.any():
heat[:spark_zone] = np.where(
mask,
np.minimum(1.0, heat[:spark_zone] + 0.4 + 0.6 * np.random.random(spark_zone)),
heat[:spark_zone],
)
# Map heat to palette (pre-allocated scratch) # Map heat to palette (pre-allocated scratch)
np.multiply(heat, 255, out=self._s_f32_a) np.multiply(heat, 255, out=self._s_f32_a)

View File

@@ -174,8 +174,8 @@ class KCTargetProcessor(TargetProcessor):
self._latest_colors = None self._latest_colors = None
# Start processing task # Start processing task
self._task = asyncio.create_task(self._processing_loop())
self._is_running = True self._is_running = True
self._task = asyncio.create_task(self._processing_loop())
logger.info(f"Started KC processing for target {self._target_id}") logger.info(f"Started KC processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True}) self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})

View File

@@ -152,10 +152,10 @@ class MappedColorStripStream(ColorStripStream):
# ── Processing loop ───────────────────────────────────────── # ── Processing loop ─────────────────────────────────────────
def _processing_loop(self) -> None: def _processing_loop(self) -> None:
frame_time = 1.0 / self._fps
try: try:
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
try: try:
target_n = self._led_count target_n = self._led_count

View File

@@ -160,8 +160,8 @@ class WledTargetProcessor(TargetProcessor):
# Reset metrics and start loop # Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._task = asyncio.create_task(self._processing_loop())
self._is_running = True self._is_running = True
self._task = asyncio.create_task(self._processing_loop())
logger.info(f"Started processing for target {self._target_id}") logger.info(f"Started processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True}) self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
@@ -889,6 +889,10 @@ class WledTargetProcessor(TargetProcessor):
await _probe_client.aclose() await _probe_client.aclose()
if _probe_task is not None and not _probe_task.done(): if _probe_task is not None and not _probe_task.done():
_probe_task.cancel() _probe_task.cancel()
try:
await _probe_task
except (asyncio.CancelledError, Exception):
pass
self._device_reachable = None self._device_reachable = None
self._metrics.device_streaming_reachable = None self._metrics.device_streaming_reachable = None
logger.info(f"Processing loop ended for target {self._target_id}") logger.info(f"Processing loop ended for target {self._target_id}")

View File

@@ -148,10 +148,11 @@ export async function saveAudioSource() {
export async function editAudioSource(sourceId) { export async function editAudioSource(sourceId) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json(); const data = await resp.json();
await showAudioSourceModal(data.source_type, data); await showAudioSourceModal(data.source_type, data);
} catch (e) { } catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
@@ -161,7 +162,7 @@ export async function editAudioSource(sourceId) {
export async function cloneAudioSource(sourceId) { export async function cloneAudioSource(sourceId) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json(); const data = await resp.json();
delete data.id; delete data.id;
data.name = data.name + ' (copy)'; data.name = data.name + ' (copy)';

View File

@@ -6,6 +6,7 @@ import {
calibrationTestState, EDGE_TEST_COLORS, calibrationTestState, EDGE_TEST_COLORS,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
@@ -138,7 +139,7 @@ export async function showCalibration(deviceId) {
fetchWithAuth('/config/displays'), fetchWithAuth('/config/displays'),
]); ]);
if (!response.ok) { showToast('Failed to load calibration', 'error'); return; } if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
const device = await response.json(); const device = await response.json();
const calibration = device.calibration; const calibration = device.calibration;
@@ -215,7 +216,7 @@ export async function showCalibration(deviceId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load calibration:', error); console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error'); showToast(t('calibration.error.load_failed'), 'error');
} }
} }
@@ -240,7 +241,7 @@ export async function showCSSCalibration(cssId) {
fetchWithAuth('/devices'), fetchWithAuth('/devices'),
]); ]);
if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; } if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const source = await cssResp.json(); const source = await cssResp.json();
const calibration = source.calibration || {}; const calibration = source.calibration || {};
@@ -339,7 +340,7 @@ export async function showCSSCalibration(cssId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load CSS calibration:', error); console.error('Failed to load CSS calibration:', error);
showToast('Failed to load calibration', 'error'); showToast(t('calibration.error.load_failed'), 'error');
} }
} }
@@ -841,13 +842,13 @@ export async function toggleTestEdge(edge) {
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`; error.textContent = t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err); console.error('Failed to toggle CSS test edge:', err);
error.textContent = 'Failed to toggle test edge'; error.textContent = t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
return; return;
@@ -871,13 +872,13 @@ export async function toggleTestEdge(edge) {
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`; error.textContent = t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle test edge:', err); console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge'; error.textContent = t('calibration.error.test_toggle_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} }
@@ -920,13 +921,13 @@ export async function saveCalibration() {
: parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0; : parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0;
if (!cssMode) { if (!cssMode) {
if (total !== declaredLedCount) { if (total !== declaredLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${declaredLedCount})`; error.textContent = t('calibration.error.led_count_mismatch');
error.style.display = 'block'; error.style.display = 'block';
return; return;
} }
} else { } else {
if (declaredLedCount > 0 && total > declaredLedCount) { if (declaredLedCount > 0 && total > declaredLedCount) {
error.textContent = `Calibrated LEDs (${total}) exceed total LED count (${declaredLedCount})`; error.textContent = t('calibration.error.led_count_exceeded');
error.style.display = 'block'; error.style.display = 'block';
return; return;
} }
@@ -963,7 +964,7 @@ export async function saveCalibration() {
}); });
} }
if (response.ok) { if (response.ok) {
showToast('Calibration saved', 'success'); showToast(t('calibration.saved'), 'success');
calibModal.forceClose(); calibModal.forceClose();
if (cssMode) { if (cssMode) {
if (window.loadTargetsTab) window.loadTargetsTab(); if (window.loadTargetsTab) window.loadTargetsTab();
@@ -972,13 +973,13 @@ export async function saveCalibration() {
} }
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`; error.textContent = t('calibration.error.save_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to save calibration:', err); console.error('Failed to save calibration:', err);
error.textContent = 'Failed to save calibration'; error.textContent = t('calibration.error.save_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} }

View File

@@ -525,7 +525,7 @@ async function _loadAudioSources() {
if (!select) return; if (!select) return;
try { try {
const resp = await fetchWithAuth('/audio-sources'); const resp = await fetchWithAuth('/audio-sources');
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
const sources = data.sources || []; const sources = data.sources || [];
select.innerHTML = sources.map(s => { select.innerHTML = sources.map(s => {
@@ -905,7 +905,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to open CSS editor:', error); console.error('Failed to open CSS editor:', error);
showToast('Failed to open color strip editor', 'error'); showToast(t('color_strip.error.editor_open_failed'), 'error');
} }
} }
@@ -1097,7 +1097,7 @@ export async function cloneColorStrip(cssId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone color strip:', error); console.error('Failed to clone color strip:', error);
showToast('Failed to clone color strip source', 'error'); showToast(t('color_strip.error.clone_failed'), 'error');
} }
} }
@@ -1122,7 +1122,7 @@ export async function deleteColorStrip(cssId) {
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to delete color strip source', 'error'); showToast(t('color_strip.error.delete_failed'), 'error');
} }
} }

View File

@@ -198,10 +198,16 @@ function _updateRunningMetrics(enrichedRunning) {
if (chart) { if (chart) {
const actualH = _fpsHistory[target.id] || []; const actualH = _fpsHistory[target.id] || [];
const currentH = _fpsCurrentHistory[target.id] || []; const currentH = _fpsCurrentHistory[target.id] || [];
chart.data.datasets[0].data = [...actualH]; // Mutate in-place to avoid array copies
chart.data.datasets[1].data = [...currentH]; const ds0 = chart.data.datasets[0].data;
chart.data.labels = actualH.map(() => ''); ds0.length = 0;
chart.update(); ds0.push(...actualH);
const ds1 = chart.data.datasets[1].data;
ds1.length = 0;
ds1.push(...currentH);
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
chart.data.labels.length = ds0.length;
chart.update('none');
} }
// Refresh uptime base for interpolation // Refresh uptime base for interpolation
@@ -366,11 +372,14 @@ export async function loadDashboard(forceFullRender = false) {
setTabRefreshing('dashboard-content', true); setTabRefreshing('dashboard-content', true);
try { try {
const [targetsResp, profilesResp, devicesResp, cssResp] = await Promise.all([ // Fire all requests in a single batch to avoid sequential RTTs
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp] = await Promise.all([
fetchWithAuth('/picture-targets'), fetchWithAuth('/picture-targets'),
fetchWithAuth('/profiles').catch(() => null), fetchWithAuth('/profiles').catch(() => null),
fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/devices').catch(() => null),
fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
]); ]);
const targetsData = await targetsResp.json(); const targetsData = await targetsResp.json();
@@ -384,6 +393,9 @@ export async function loadDashboard(forceFullRender = false) {
const cssSourceMap = {}; const cssSourceMap = {};
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; } for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
// Build dynamic HTML (targets, profiles) // Build dynamic HTML (targets, profiles)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds = []; let runningIds = [];
@@ -392,12 +404,6 @@ export async function loadDashboard(forceFullRender = false) {
if (targets.length === 0 && profiles.length === 0) { if (targets.length === 0 && profiles.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
const [batchStatesResp, batchMetricsResp] = await Promise.all([
fetchWithAuth('/picture-targets/batch/states'),
fetchWithAuth('/picture-targets/batch/metrics'),
]);
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
...target, ...target,
state: allStates[target.id] || {}, state: allStates[target.id] || {},
@@ -712,7 +718,7 @@ export async function dashboardToggleProfile(profileId, enable) {
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to toggle profile', 'error'); showToast(t('dashboard.error.profile_toggle_failed'), 'error');
} }
} }
@@ -726,11 +732,11 @@ export async function dashboardStartTarget(targetId) {
loadDashboard(); loadDashboard();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error'); showToast(t('dashboard.error.start_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to start processing', 'error'); showToast(t('dashboard.error.start_failed'), 'error');
} }
} }
@@ -744,11 +750,11 @@ export async function dashboardStopTarget(targetId) {
loadDashboard(); loadDashboard();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error'); showToast(t('dashboard.error.stop_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to stop processing', 'error'); showToast(t('dashboard.error.stop_failed'), 'error');
} }
} }
@@ -763,26 +769,31 @@ export async function dashboardToggleAutoStart(targetId, enable) {
loadDashboard(); loadDashboard();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed: ${error.detail}`, 'error'); showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to toggle auto-start', 'error'); showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
} }
} }
export async function dashboardStopAll() { export async function dashboardStopAll() {
try { try {
const targetsResp = await fetchWithAuth('/picture-targets'); const [targetsResp, statesResp] = await Promise.all([
fetchWithAuth('/picture-targets'),
fetchWithAuth('/picture-targets/batch/states'),
]);
const data = await targetsResp.json(); const data = await targetsResp.json();
const running = (data.targets || []).filter(t => t.id); const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
const states = statesData.states || {};
const running = (data.targets || []).filter(t => states[t.id]?.processing);
await Promise.all(running.map(t => await Promise.all(running.map(t =>
fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {}) fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
)); ));
loadDashboard(); loadDashboard();
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to stop all targets', 'error'); showToast(t('dashboard.error.stop_all'), 'error');
} }
} }

View File

@@ -317,7 +317,7 @@ export async function handleAddDevice(event) {
} }
if (!name || (!isMockDevice(deviceType) && !url)) { if (!name || (!isMockDevice(deviceType) && !url)) {
error.textContent = 'Please fill in all fields'; error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block'; error.style.display = 'block';
return; return;
} }
@@ -351,7 +351,7 @@ export async function handleAddDevice(event) {
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
console.log('Device added successfully:', result); console.log('Device added successfully:', result);
showToast('Device added successfully', 'success'); showToast(t('device_discovery.added'), 'success');
addDeviceModal.forceClose(); addDeviceModal.forceClose();
// Use window.* to avoid circular imports // Use window.* to avoid circular imports
if (typeof window.loadDevices === 'function') await window.loadDevices(); if (typeof window.loadDevices === 'function') await window.loadDevices();
@@ -365,12 +365,12 @@ export async function handleAddDevice(event) {
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error('Failed to add device:', errorData); console.error('Failed to add device:', errorData);
error.textContent = `Failed to add device: ${errorData.detail}`; error.textContent = t('device_discovery.error.add_failed');
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to add device:', err); console.error('Failed to add device:', err);
showToast('Failed to add device', 'error'); showToast(t('device_discovery.error.add_failed'), 'error');
} }
} }

View File

@@ -126,7 +126,7 @@ export async function turnOffDevice(deviceId) {
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to turn off device', 'error'); showToast(t('device.error.power_off_failed'), 'error');
} }
} }
@@ -143,23 +143,23 @@ export async function removeDevice(deviceId) {
method: 'DELETE', method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
showToast('Device removed', 'success'); showToast(t('device.removed'), 'success');
window.loadDevices(); window.loadDevices();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to remove: ${error.detail}`, 'error'); showToast(t('device.error.remove_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to remove device:', error); console.error('Failed to remove device:', error);
showToast('Failed to remove device', 'error'); showToast(t('device.error.remove_failed'), 'error');
} }
} }
export async function showSettings(deviceId) { export async function showSettings(deviceId) {
try { try {
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`); const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; } if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
const device = await deviceResponse.json(); const device = await deviceResponse.json();
const isAdalight = isSerialDevice(device.device_type); const isAdalight = isSerialDevice(device.device_type);
@@ -171,7 +171,7 @@ export async function showSettings(deviceId) {
document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-health-interval').value = 30; document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
const isMock = isMockDevice(device.device_type); const isMock = isMockDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-group'); const urlGroup = document.getElementById('settings-url-group');
@@ -242,7 +242,7 @@ export async function showSettings(deviceId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load device settings:', error); console.error('Failed to load device settings:', error);
showToast('Failed to load device settings', 'error'); showToast(t('device.error.settings_load_failed'), 'error');
} }
} }
@@ -256,12 +256,16 @@ export async function saveDeviceSettings() {
const url = settingsModal._getUrl(); const url = settingsModal._getUrl();
if (!name || !url) { if (!name || !url) {
settingsModal.showError('Please fill in all fields correctly'); settingsModal.showError(t('device.error.required'));
return; return;
} }
try { try {
const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked }; const body = {
name, url,
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
};
const ledCountInput = document.getElementById('settings-led-count'); const ledCountInput = document.getElementById('settings-led-count');
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) { if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10); body.led_count = parseInt(ledCountInput.value, 10);
@@ -283,7 +287,7 @@ export async function saveDeviceSettings() {
if (!deviceResponse.ok) { if (!deviceResponse.ok) {
const errorData = await deviceResponse.json(); const errorData = await deviceResponse.json();
settingsModal.showError(`Failed to update device: ${errorData.detail}`); settingsModal.showError(t('device.error.update'));
return; return;
} }
@@ -293,7 +297,7 @@ export async function saveDeviceSettings() {
} catch (err) { } catch (err) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to save device settings:', err); console.error('Failed to save device settings:', err);
settingsModal.showError('Failed to save settings'); settingsModal.showError(t('device.error.save'));
} }
} }
@@ -307,14 +311,16 @@ export async function saveCardBrightness(deviceId, value) {
const bri = parseInt(value); const bri = parseInt(value);
updateDeviceBrightness(deviceId, bri); updateDeviceBrightness(deviceId, bri);
try { try {
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness: bri }) body: JSON.stringify({ brightness: bri })
}); });
if (!resp.ok) {
showToast(t('device.error.brightness'), 'error');
}
} catch (err) { } catch (err) {
console.error('Failed to update brightness:', err); if (err.isAuth) return;
showToast('Failed to update brightness', 'error'); showToast(t('device.error.brightness'), 'error');
} }
} }
@@ -323,9 +329,7 @@ export async function fetchDeviceBrightness(deviceId) {
if (_brightnessFetchInFlight.has(deviceId)) return; if (_brightnessFetchInFlight.has(deviceId)) return;
_brightnessFetchInFlight.add(deviceId); _brightnessFetchInFlight.add(deviceId);
try { try {
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
headers: getHeaders()
});
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness); updateDeviceBrightness(deviceId, data.brightness);
@@ -398,9 +402,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
try { try {
const discoverType = settingsModal.deviceType || 'adalight'; const discoverType = settingsModal.deviceType || 'adalight';
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, { const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
headers: getHeaders()
});
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
const devices = data.devices || []; const devices = data.devices || [];

View File

@@ -505,7 +505,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100); setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
} catch (error) { } catch (error) {
console.error('Failed to open KC editor:', error); console.error('Failed to open KC editor:', error);
showToast('Failed to open key colors editor', 'error'); showToast(t('kc_target.error.editor_open_failed'), 'error');
} }
} }
@@ -588,13 +588,13 @@ export async function saveKCEditor() {
export async function cloneKCTarget(targetId) { export async function cloneKCTarget(targetId) {
try { try {
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); const resp = await fetchWithAuth(`/picture-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target'); if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json(); const target = await resp.json();
showKCEditor(null, target); showKCEditor(null, target);
} catch (error) { } catch (error) {
console.error('Failed to clone KC target:', error); if (error.isAuth) return;
showToast('Failed to clone key colors target', 'error'); showToast(t('kc_target.error.clone_failed'), 'error');
} }
} }
@@ -613,11 +613,11 @@ export async function deleteKCTarget(targetId) {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(t('kc_target.error.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to delete key colors target', 'error'); showToast(t('kc_target.error.delete_failed'), 'error');
} }
} }

View File

@@ -125,7 +125,7 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
setTimeout(() => document.getElementById('pattern-template-name').focus(), 100); setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
} catch (error) { } catch (error) {
console.error('Failed to open pattern template editor:', error); console.error('Failed to open pattern template editor:', error);
showToast('Failed to open pattern template editor', 'error'); showToast(t('pattern.error.editor_open_failed'), 'error');
} }
} }
@@ -189,13 +189,13 @@ export async function savePatternTemplate() {
export async function clonePatternTemplate(templateId) { export async function clonePatternTemplate(templateId) {
try { try {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() }); const resp = await fetchWithAuth(`/pattern-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load pattern template'); if (!resp.ok) throw new Error('Failed to load pattern template');
const tmpl = await resp.json(); const tmpl = await resp.json();
showPatternTemplateEditor(null, tmpl); showPatternTemplateEditor(null, tmpl);
} catch (error) { } catch (error) {
console.error('Failed to clone pattern template:', error); if (error.isAuth) return;
showToast('Failed to clone pattern template', 'error'); showToast(t('pattern.error.clone_failed'), 'error');
} }
} }
@@ -216,11 +216,11 @@ export async function deletePatternTemplate(templateId) {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(t('pattern.error.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast('Failed to delete pattern template', 'error'); showToast(t('pattern.error.delete_failed'), 'error');
} }
} }
@@ -825,6 +825,6 @@ export async function capturePatternBackground() {
} }
} catch (error) { } catch (error) {
console.error('Failed to capture background:', error); console.error('Failed to capture background:', error);
showToast('Failed to capture background', 'error'); showToast(t('pattern.error.capture_bg_failed'), 'error');
} }
} }

View File

@@ -144,9 +144,13 @@ function _pushSample(key, value) {
if (_history[key].length > MAX_SAMPLES) _history[key].shift(); if (_history[key].length > MAX_SAMPLES) _history[key].shift();
const chart = _charts[key]; const chart = _charts[key];
if (!chart) return; if (!chart) return;
chart.data.datasets[0].data = [..._history[key]]; const ds = chart.data.datasets[0].data;
chart.data.labels = _history[key].map(() => ''); ds.length = 0;
chart.update(); ds.push(..._history[key]);
// Ensure labels array matches length (reuse existing array)
while (chart.data.labels.length < ds.length) chart.data.labels.push('');
chart.data.labels.length = ds.length;
chart.update('none');
} }
async function _fetchPerformance() { async function _fetchPerformance() {

View File

@@ -242,19 +242,26 @@ function restoreCaptureDuration() {
} }
export async function showTestTemplateModal(templateId) { export async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json()); try {
const template = templates.templates.find(t => t.id === templateId); const resp = await fetchWithAuth('/capture-templates');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const template = (data.templates || []).find(tp => tp.id === templateId);
if (!template) { if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
window.currentTestingTemplate = template;
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error'); showToast(t('templates.error.load'), 'error');
return;
} }
window.currentTestingTemplate = template;
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
} }
export function closeTestTemplateModal() { export function closeTestTemplateModal() {
@@ -871,7 +878,7 @@ export async function cloneAudioTemplate(templateId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone audio template:', error); console.error('Failed to clone audio template:', error);
showToast('Failed to clone audio template', 'error'); showToast(t('audio_template.error.clone_failed'), 'error');
} }
} }
@@ -2213,7 +2220,7 @@ export async function cloneStream(streamId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone stream:', error); console.error('Failed to clone stream:', error);
showToast('Failed to clone picture source', 'error'); showToast(t('stream.error.clone_picture_failed'), 'error');
} }
} }
@@ -2226,7 +2233,7 @@ export async function cloneCaptureTemplate(templateId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone capture template:', error); console.error('Failed to clone capture template:', error);
showToast('Failed to clone capture template', 'error'); showToast(t('stream.error.clone_capture_failed'), 'error');
} }
} }
@@ -2239,7 +2246,7 @@ export async function clonePPTemplate(templateId) {
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone PP template:', error); console.error('Failed to clone PP template:', error);
showToast('Failed to clone postprocessing template', 'error'); showToast(t('stream.error.clone_pp_failed'), 'error');
} }
} }

View File

@@ -113,9 +113,15 @@ function _updateTargetFpsChart(targetId, fpsTarget) {
if (!chart) return; if (!chart) return;
const actualH = _targetFpsHistory[targetId] || []; const actualH = _targetFpsHistory[targetId] || [];
const currentH = _targetFpsCurrentHistory[targetId] || []; const currentH = _targetFpsCurrentHistory[targetId] || [];
chart.data.labels = actualH.map(() => ''); // Mutate in-place to avoid array copies
chart.data.datasets[0].data = [...actualH]; const ds0 = chart.data.datasets[0].data;
chart.data.datasets[1].data = [...currentH]; ds0.length = 0;
ds0.push(...actualH);
const ds1 = chart.data.datasets[1].data;
ds1.length = 0;
ds1.push(...currentH);
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
chart.data.labels.length = ds0.length;
chart.options.scales.y.max = fpsTarget * 1.15; chart.options.scales.y.max = fpsTarget * 1.15;
chart.update('none'); chart.update('none');
} }
@@ -352,7 +358,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
setTimeout(() => document.getElementById('target-editor-name').focus(), 100); setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
} catch (error) { } catch (error) {
console.error('Failed to open target editor:', error); console.error('Failed to open target editor:', error);
showToast('Failed to open target editor', 'error'); showToast(t('target.error.editor_open_failed'), 'error');
} }
} }
@@ -972,7 +978,7 @@ export async function startTargetProcessing(targetId) {
showToast(t('device.started'), 'success'); showToast(t('device.started'), 'success');
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error'); showToast(t('target.error.start_failed'), 'error');
} }
}); });
} }
@@ -986,7 +992,7 @@ export async function stopTargetProcessing(targetId) {
showToast(t('device.stopped'), 'success'); showToast(t('device.stopped'), 'success');
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error'); showToast(t('target.error.stop_failed'), 'error');
} }
}); });
} }
@@ -1027,7 +1033,7 @@ export async function cloneTarget(targetId) {
showTargetEditor(null, target); showTargetEditor(null, target);
} catch (error) { } catch (error) {
console.error('Failed to clone target:', error); console.error('Failed to clone target:', error);
showToast('Failed to clone target', 'error'); showToast(t('target.error.clone_failed'), 'error');
} }
} }
@@ -1042,11 +1048,11 @@ export async function toggleTargetAutoStart(targetId, enable) {
loadTargetsTab(); loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed: ${error.detail}`, 'error'); showToast(t('target.error.autostart_toggle_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Failed to toggle auto-start:', error); console.error('Failed to toggle auto-start:', error);
showToast('Failed to toggle auto-start', 'error'); showToast(t('target.error.autostart_toggle_failed'), 'error');
} }
} }
@@ -1062,7 +1068,7 @@ export async function deleteTarget(targetId) {
showToast(t('targets.deleted'), 'success'); showToast(t('targets.deleted'), 'success');
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(t('target.error.delete_failed'), 'error');
} }
}); });
} }

View File

@@ -239,10 +239,11 @@ export async function saveValueSource() {
export async function editValueSource(sourceId) { export async function editValueSource(sourceId) {
try { try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`); const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) throw new Error(t('value_source.error.load'));
const data = await resp.json(); const data = await resp.json();
await showValueSourceModal(data); await showValueSourceModal(data);
} catch (e) { } catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
@@ -252,7 +253,7 @@ export async function editValueSource(sourceId) {
export async function cloneValueSource(sourceId) { export async function cloneValueSource(sourceId) {
try { try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`); const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) throw new Error(t('value_source.error.load'));
const data = await resp.json(); const data = await resp.json();
delete data.id; delete data.id;
data.name = data.name + ' (copy)'; data.name = data.name + ' (copy)';

View File

@@ -992,5 +992,54 @@
"settings.saved_backups.download": "Download", "settings.saved_backups.download": "Download",
"settings.saved_backups.delete": "Delete", "settings.saved_backups.delete": "Delete",
"settings.saved_backups.delete_confirm": "Delete this backup file?", "settings.saved_backups.delete_confirm": "Delete this backup file?",
"settings.saved_backups.delete_error": "Failed to delete backup" "settings.saved_backups.delete_error": "Failed to delete backup",
"device.error.power_off_failed": "Failed to turn off device",
"device.removed": "Device removed",
"device.error.remove_failed": "Failed to remove device",
"device.error.settings_load_failed": "Failed to load device settings",
"device.error.brightness": "Failed to update brightness",
"device.error.required": "Please fill in all fields correctly",
"device.error.update": "Failed to update device",
"device.error.save": "Failed to save settings",
"device_discovery.error.fill_all_fields": "Please fill in all fields",
"device_discovery.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device",
"calibration.error.load_failed": "Failed to load calibration",
"calibration.error.css_load_failed": "Failed to load color strip source",
"calibration.error.test_toggle_failed": "Failed to toggle test edge",
"calibration.saved": "Calibration saved",
"calibration.error.save_failed": "Failed to save calibration",
"calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count",
"calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count",
"dashboard.error.profile_toggle_failed": "Failed to toggle profile",
"dashboard.error.start_failed": "Failed to start processing",
"dashboard.error.stop_failed": "Failed to stop processing",
"dashboard.error.autostart_toggle_failed": "Failed to toggle auto-start",
"dashboard.error.stop_all": "Failed to stop all targets",
"target.error.editor_open_failed": "Failed to open target editor",
"target.error.start_failed": "Failed to start target",
"target.error.stop_failed": "Failed to stop target",
"target.error.clone_failed": "Failed to clone target",
"target.error.autostart_toggle_failed": "Failed to toggle auto-start",
"target.error.delete_failed": "Failed to delete target",
"audio_source.error.load": "Failed to load audio source",
"audio_template.error.clone_failed": "Failed to clone audio template",
"value_source.error.load": "Failed to load value source",
"color_strip.error.editor_open_failed": "Failed to open color strip editor",
"color_strip.error.clone_failed": "Failed to clone color strip source",
"color_strip.error.delete_failed": "Failed to delete color strip source",
"pattern.error.editor_open_failed": "Failed to open pattern template editor",
"pattern.error.clone_failed": "Failed to clone pattern template",
"pattern.error.delete_failed": "Failed to delete pattern template",
"pattern.error.capture_bg_failed": "Failed to capture background",
"stream.error.clone_picture_failed": "Failed to clone picture source",
"stream.error.clone_capture_failed": "Failed to clone capture template",
"stream.error.clone_pp_failed": "Failed to clone postprocessing template",
"kc_target.error.editor_open_failed": "Failed to open key colors editor",
"kc_target.error.clone_failed": "Failed to clone key colors target",
"kc_target.error.delete_failed": "Failed to delete key colors target",
"theme.switched.dark": "Switched to dark theme",
"theme.switched.light": "Switched to light theme",
"accent.color.updated": "Accent color updated",
"search.footer": "↑↓ navigate · Enter select · Esc close"
} }

View File

@@ -992,5 +992,54 @@
"settings.saved_backups.download": "Скачать", "settings.saved_backups.download": "Скачать",
"settings.saved_backups.delete": "Удалить", "settings.saved_backups.delete": "Удалить",
"settings.saved_backups.delete_confirm": "Удалить эту резервную копию?", "settings.saved_backups.delete_confirm": "Удалить эту резервную копию?",
"settings.saved_backups.delete_error": "Не удалось удалить копию" "settings.saved_backups.delete_error": "Не удалось удалить копию",
"device.error.power_off_failed": "Не удалось выключить устройство",
"device.removed": "Устройство удалено",
"device.error.remove_failed": "Не удалось удалить устройство",
"device.error.settings_load_failed": "Не удалось загрузить настройки устройства",
"device.error.brightness": "Не удалось обновить яркость",
"device.error.required": "Пожалуйста, заполните все поля",
"device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство",
"calibration.error.load_failed": "Не удалось загрузить калибровку",
"calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы",
"calibration.error.test_toggle_failed": "Не удалось переключить тестовый край",
"calibration.saved": "Калибровка сохранена",
"calibration.error.save_failed": "Не удалось сохранить калибровку",
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
"dashboard.error.profile_toggle_failed": "Не удалось переключить профиль",
"dashboard.error.start_failed": "Не удалось запустить обработку",
"dashboard.error.stop_failed": "Не удалось остановить обработку",
"dashboard.error.autostart_toggle_failed": "Не удалось переключить автозапуск",
"dashboard.error.stop_all": "Не удалось остановить все цели",
"target.error.editor_open_failed": "Не удалось открыть редактор цели",
"target.error.start_failed": "Не удалось запустить цель",
"target.error.stop_failed": "Не удалось остановить цель",
"target.error.clone_failed": "Не удалось клонировать цель",
"target.error.autostart_toggle_failed": "Не удалось переключить автозапуск",
"target.error.delete_failed": "Не удалось удалить цель",
"audio_source.error.load": "Не удалось загрузить аудиоисточник",
"audio_template.error.clone_failed": "Не удалось клонировать аудиошаблон",
"value_source.error.load": "Не удалось загрузить источник значений",
"color_strip.error.editor_open_failed": "Не удалось открыть редактор цветовой полосы",
"color_strip.error.clone_failed": "Не удалось клонировать источник цветовой полосы",
"color_strip.error.delete_failed": "Не удалось удалить источник цветовой полосы",
"pattern.error.editor_open_failed": "Не удалось открыть редактор шаблона узоров",
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
"kc_target.error.editor_open_failed": "Не удалось открыть редактор ключевых цветов",
"kc_target.error.clone_failed": "Не удалось клонировать цель ключевых цветов",
"kc_target.error.delete_failed": "Не удалось удалить цель ключевых цветов",
"theme.switched.dark": "Переключено на тёмную тему",
"theme.switched.light": "Переключено на светлую тему",
"accent.color.updated": "Цвет акцента обновлён",
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть"
} }

View File

@@ -992,5 +992,54 @@
"settings.saved_backups.download": "下载", "settings.saved_backups.download": "下载",
"settings.saved_backups.delete": "删除", "settings.saved_backups.delete": "删除",
"settings.saved_backups.delete_confirm": "删除此备份文件?", "settings.saved_backups.delete_confirm": "删除此备份文件?",
"settings.saved_backups.delete_error": "删除备份失败" "settings.saved_backups.delete_error": "删除备份失败",
"device.error.power_off_failed": "关闭设备失败",
"device.removed": "设备已移除",
"device.error.remove_failed": "移除设备失败",
"device.error.settings_load_failed": "加载设备设置失败",
"device.error.brightness": "更新亮度失败",
"device.error.required": "请填写所有字段",
"device.error.update": "更新设备失败",
"device.error.save": "保存设置失败",
"device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败",
"calibration.error.load_failed": "加载校准失败",
"calibration.error.css_load_failed": "加载色带源失败",
"calibration.error.test_toggle_failed": "切换测试边缘失败",
"calibration.saved": "校准已保存",
"calibration.error.save_failed": "保存校准失败",
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
"dashboard.error.profile_toggle_failed": "切换配置文件失败",
"dashboard.error.start_failed": "启动处理失败",
"dashboard.error.stop_failed": "停止处理失败",
"dashboard.error.autostart_toggle_failed": "切换自动启动失败",
"dashboard.error.stop_all": "停止所有目标失败",
"target.error.editor_open_failed": "打开目标编辑器失败",
"target.error.start_failed": "启动目标失败",
"target.error.stop_failed": "停止目标失败",
"target.error.clone_failed": "克隆目标失败",
"target.error.autostart_toggle_failed": "切换自动启动失败",
"target.error.delete_failed": "删除目标失败",
"audio_source.error.load": "加载音频源失败",
"audio_template.error.clone_failed": "克隆音频模板失败",
"value_source.error.load": "加载数值源失败",
"color_strip.error.editor_open_failed": "打开色带编辑器失败",
"color_strip.error.clone_failed": "克隆色带源失败",
"color_strip.error.delete_failed": "删除色带源失败",
"pattern.error.editor_open_failed": "打开图案模板编辑器失败",
"pattern.error.clone_failed": "克隆图案模板失败",
"pattern.error.delete_failed": "删除图案模板失败",
"pattern.error.capture_bg_failed": "捕获背景失败",
"stream.error.clone_picture_failed": "克隆图片源失败",
"stream.error.clone_capture_failed": "克隆捕获模板失败",
"stream.error.clone_pp_failed": "克隆后处理模板失败",
"kc_target.error.editor_open_failed": "打开关键颜色编辑器失败",
"kc_target.error.clone_failed": "克隆关键颜色目标失败",
"kc_target.error.delete_failed": "删除关键颜色目标失败",
"theme.switched.dark": "已切换到深色主题",
"theme.switched.light": "已切换到浅色主题",
"accent.color.updated": "强调色已更新",
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭"
} }

View File

@@ -170,7 +170,7 @@
<div class="cp-dialog"> <div class="cp-dialog">
<input id="cp-input" class="cp-input" placeholder="Search..." autocomplete="off"> <input id="cp-input" class="cp-input" placeholder="Search..." autocomplete="off">
<div id="cp-results" class="cp-results"></div> <div id="cp-results" class="cp-results"></div>
<div class="cp-footer">↑↓ navigate · Enter select · Esc close</div> <div class="cp-footer" data-i18n="search.footer">↑↓ navigate · Enter select · Esc close</div>
</div> </div>
</div> </div>
@@ -198,7 +198,7 @@
// Re-derive accent text variant for the new theme // Re-derive accent text variant for the new theme
const accent = localStorage.getItem('accentColor'); const accent = localStorage.getItem('accentColor');
if (accent) applyAccentColor(accent, true); if (accent) applyAccentColor(accent, true);
showToast(`Switched to ${newTheme} theme`, 'info'); showToast(window.t ? t(newTheme === 'dark' ? 'theme.switched.dark' : 'theme.switched.light') : `Switched to ${newTheme} theme`, 'info');
} }
// Initialize accent color // Initialize accent color
@@ -247,7 +247,7 @@
if (native) native.value = hex; if (native) native.value = hex;
localStorage.setItem('accentColor', hex); localStorage.setItem('accentColor', hex);
document.dispatchEvent(new CustomEvent('accentColorChanged', { detail: { color: hex } })); document.dispatchEvent(new CustomEvent('accentColorChanged', { detail: { color: hex } }));
if (!silent) showToast('Accent color updated', 'info'); if (!silent) showToast(window.t ? t('accent.color.updated') : 'Accent color updated', 'info');
} }
// Bootstrap _cpToggle/_cpPick globals before the color-picker module loads // Bootstrap _cpToggle/_cpPick globals before the color-picker module loads

View File

@@ -1,9 +1,9 @@
<!-- Audio Source Editor Modal --> <!-- Audio Source Editor Modal -->
<div id="audio-source-modal" class="modal" role="dialog" aria-labelledby="audio-source-modal-title"> <div id="audio-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="audio-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="audio-source-modal-title" data-i18n="audio_source.add">Add Audio Source</h2> <h2 id="audio-source-modal-title" data-i18n="audio_source.add">Add Audio Source</h2>
<button class="modal-close-btn" onclick="closeAudioSourceModal()" aria-label="Close">&times;</button> <button class="modal-close-btn" onclick="closeAudioSourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="audio-source-form" onsubmit="return false;"> <form id="audio-source-form" onsubmit="return false;">

View File

@@ -9,8 +9,8 @@
<p id="confirm-message" class="modal-description"></p> <p id="confirm-message" class="modal-description"></p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-danger" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button> <button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button>
<button class="btn btn-secondary" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button> <button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -75,5 +75,8 @@
<div id="settings-error" class="error-message" style="display:none;"></div> <div id="settings-error" class="error-message" style="display:none;"></div>
</div> </div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeSettingsModal()" title="Close" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Value Source Editor Modal --> <!-- Value Source Editor Modal -->
<div id="value-source-modal" class="modal" role="dialog" aria-labelledby="value-source-modal-title"> <div id="value-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="value-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="value-source-modal-title" data-i18n="value_source.add">Add Value Source</h2> <h2 id="value-source-modal-title" data-i18n="value_source.add">Add Value Source</h2>
<button class="modal-close-btn" onclick="closeValueSourceModal()" aria-label="Close">&times;</button> <button class="modal-close-btn" onclick="closeValueSourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="value-source-form" onsubmit="return false;"> <form id="value-source-form" onsubmit="return false;">