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:
@@ -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`).
|
||||
|
||||
## 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
|
||||
|
||||
- Always test changes before marking as complete
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Picture target routes: CRUD, processing control, settings, state, metrics."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
@@ -297,9 +298,10 @@ async def update_target(
|
||||
if "brightness_value_source_id" in kc_incoming:
|
||||
kc_brightness_vs_changed = True
|
||||
|
||||
# Sync processor manager
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
try:
|
||||
target.sync_with_manager(
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(data.fps is not None or
|
||||
data.keepalive_interval is not None or
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""System routes: health, version, displays, performance, backup/restore, ADB."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import platform
|
||||
@@ -372,15 +373,20 @@ async def restore_config(
|
||||
if not isinstance(stores[key], dict):
|
||||
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()
|
||||
written = 0
|
||||
|
||||
def _write_stores():
|
||||
count = 0
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
if store_key in stores:
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
atomic_write_json(file_path, stores[store_key])
|
||||
written += 1
|
||||
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...")
|
||||
_schedule_restart()
|
||||
|
||||
@@ -99,6 +99,12 @@ class AudioAnalyzer:
|
||||
self._spectrum_buf_right = np.zeros(NUM_BANDS, 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
|
||||
self._left_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 *= (1.0 / chunk_size)
|
||||
fft_len = len(fft_mag)
|
||||
for b, (s, e) in enumerate(self._bands):
|
||||
if s < fft_len and e <= fft_len:
|
||||
buf[b] = float(np.mean(fft_mag[s:e]))
|
||||
else:
|
||||
buf[b] = 0.0
|
||||
# Vectorized band binning using cumulative sum
|
||||
valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
|
||||
buf[:] = 0.0
|
||||
if valid.any():
|
||||
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))
|
||||
if spec_max > 1e-6:
|
||||
buf *= (1.0 / spec_max)
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Pixel processing utilities for color correction and manipulation."""
|
||||
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Union
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
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(
|
||||
current_colors: List[Tuple[int, int, int]],
|
||||
previous_colors: List[Tuple[int, int, int]],
|
||||
current_colors: ColorList,
|
||||
previous_colors: ColorList,
|
||||
smoothing_factor: float = 0.5,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Smooth color transitions between frames.
|
||||
|
||||
Args:
|
||||
@@ -21,96 +30,71 @@ def smooth_colors(
|
||||
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
|
||||
|
||||
Returns:
|
||||
Smoothed colors
|
||||
Smoothed colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not current_colors or not previous_colors:
|
||||
return current_colors
|
||||
if not len(current_colors) or not len(previous_colors):
|
||||
return _as_array(current_colors)
|
||||
|
||||
if len(current_colors) != len(previous_colors):
|
||||
logger.warning(
|
||||
f"Color count mismatch: current={len(current_colors)}, "
|
||||
f"previous={len(previous_colors)}. Skipping smoothing."
|
||||
)
|
||||
return current_colors
|
||||
return _as_array(current_colors)
|
||||
|
||||
if smoothing_factor <= 0:
|
||||
return current_colors
|
||||
return _as_array(current_colors)
|
||||
if smoothing_factor >= 1:
|
||||
return previous_colors
|
||||
return _as_array(previous_colors)
|
||||
|
||||
# Convert to numpy arrays
|
||||
current = np.array(current_colors, dtype=np.float32)
|
||||
previous = np.array(previous_colors, dtype=np.float32)
|
||||
|
||||
# Blend between current and previous
|
||||
current = np.asarray(current_colors, dtype=np.float32)
|
||||
previous = np.asarray(previous_colors, dtype=np.float32)
|
||||
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
|
||||
|
||||
# Convert back to integers
|
||||
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||
|
||||
return [tuple(color) for color in smoothed]
|
||||
return np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def adjust_brightness_global(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
colors: ColorList,
|
||||
target_brightness: int,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Adjust colors to achieve target global brightness.
|
||||
|
||||
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)
|
||||
|
||||
Returns:
|
||||
Adjusted colors
|
||||
Adjusted colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not colors or target_brightness == 255:
|
||||
return colors
|
||||
arr = _as_array(colors)
|
||||
if not len(arr) or target_brightness == 255:
|
||||
return arr
|
||||
|
||||
# Calculate scaling factor
|
||||
scale = target_brightness / 255.0
|
||||
|
||||
# Scale all colors
|
||||
scaled = [
|
||||
(
|
||||
int(r * scale),
|
||||
int(g * scale),
|
||||
int(b * scale),
|
||||
)
|
||||
for r, g, b in colors
|
||||
]
|
||||
|
||||
return scaled
|
||||
return (arr.astype(np.float32) * scale).astype(np.uint8)
|
||||
|
||||
|
||||
def limit_brightness(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
colors: ColorList,
|
||||
max_brightness: int = 255,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Limit maximum brightness of any color channel.
|
||||
|
||||
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)
|
||||
|
||||
Returns:
|
||||
Limited colors
|
||||
Limited colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not colors or max_brightness == 255:
|
||||
return colors
|
||||
arr = _as_array(colors)
|
||||
if not len(arr) or max_brightness == 255:
|
||||
return arr
|
||||
|
||||
limited = []
|
||||
for r, g, b in colors:
|
||||
# Find max channel value
|
||||
max_val = max(r, g, b)
|
||||
|
||||
if max_val > max_brightness:
|
||||
# Scale down proportionally
|
||||
scale = max_brightness / max_val
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
|
||||
limited.append((r, g, b))
|
||||
|
||||
return limited
|
||||
arr_f = arr.astype(np.float32)
|
||||
max_vals = np.max(arr_f, axis=1)
|
||||
need_scale = max_vals > max_brightness
|
||||
if need_scale.any():
|
||||
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
|
||||
arr_f *= scales[:, np.newaxis]
|
||||
return arr_f.astype(np.uint8)
|
||||
|
||||
@@ -404,8 +404,13 @@ class WLEDClient(LEDClient):
|
||||
"""
|
||||
try:
|
||||
# Build indexed pixel array: [led_index, r, g, b, ...]
|
||||
indices = np.arange(len(pixels), dtype=np.int32).reshape(-1, 1)
|
||||
indexed_pixels = np.hstack([indices, pixels.astype(np.int32)]).ravel().tolist()
|
||||
n = len(pixels)
|
||||
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
|
||||
payload = {
|
||||
|
||||
@@ -24,6 +24,25 @@ DEFAULT_SCAN_TIMEOUT = 3.0
|
||||
class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
"""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
|
||||
def device_type(self) -> str:
|
||||
return "wled"
|
||||
@@ -158,16 +177,16 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
client = await self._get_client()
|
||||
resp = await client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
state = resp.json()
|
||||
return state.get("bri", 255)
|
||||
|
||||
async def set_brightness(self, url: str, brightness: int) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={"bri": brightness},
|
||||
)
|
||||
@@ -175,15 +194,15 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
client = await self._get_client()
|
||||
resp = await client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("on", False)
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={"on": on},
|
||||
)
|
||||
@@ -192,8 +211,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
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."""
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={
|
||||
"on": True,
|
||||
|
||||
@@ -356,12 +356,17 @@ class EffectColorStripStream(ColorStripStream):
|
||||
new_heat[-1] = heat[-1] * 0.5
|
||||
heat[:] = new_heat
|
||||
|
||||
# Sparks at the bottom
|
||||
# Sparks at the bottom (vectorized)
|
||||
spark_zone = max(1, n // 8)
|
||||
spark_prob = 0.3 * intensity
|
||||
for i in range(spark_zone):
|
||||
if np.random.random() < spark_prob:
|
||||
heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random())
|
||||
rng = np.random.random(spark_zone)
|
||||
mask = rng < spark_prob
|
||||
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)
|
||||
np.multiply(heat, 255, out=self._s_f32_a)
|
||||
|
||||
@@ -174,8 +174,8 @@ class KCTargetProcessor(TargetProcessor):
|
||||
self._latest_colors = None
|
||||
|
||||
# Start processing task
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
|
||||
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})
|
||||
|
||||
@@ -152,10 +152,10 @@ class MappedColorStripStream(ColorStripStream):
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
|
||||
try:
|
||||
target_n = self._led_count
|
||||
|
||||
@@ -160,8 +160,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
# Reset metrics and start loop
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
|
||||
logger.info(f"Started processing for target {self._target_id}")
|
||||
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()
|
||||
if _probe_task is not None and not _probe_task.done():
|
||||
_probe_task.cancel()
|
||||
try:
|
||||
await _probe_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._device_reachable = None
|
||||
self._metrics.device_streaming_reachable = None
|
||||
logger.info(f"Processing loop ended for target {self._target_id}")
|
||||
|
||||
@@ -148,10 +148,11 @@ export async function saveAudioSource() {
|
||||
export async function editAudioSource(sourceId) {
|
||||
try {
|
||||
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();
|
||||
await showAudioSourceModal(data.source_type, data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -161,7 +162,7 @@ export async function editAudioSource(sourceId) {
|
||||
export async function cloneAudioSource(sourceId) {
|
||||
try {
|
||||
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();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
calibrationTestState, EDGE_TEST_COLORS,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
|
||||
@@ -138,7 +139,7 @@ export async function showCalibration(deviceId) {
|
||||
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 calibration = device.calibration;
|
||||
@@ -215,7 +216,7 @@ export async function showCalibration(deviceId) {
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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'),
|
||||
]);
|
||||
|
||||
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 calibration = source.calibration || {};
|
||||
|
||||
@@ -339,7 +340,7 @@ export async function showCSSCalibration(cssId) {
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Test failed: ${errorData.detail}`;
|
||||
error.textContent = t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
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';
|
||||
}
|
||||
return;
|
||||
@@ -871,13 +872,13 @@ export async function toggleTestEdge(edge) {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Test failed: ${errorData.detail}`;
|
||||
error.textContent = t('calibration.error.test_toggle_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -920,13 +921,13 @@ export async function saveCalibration() {
|
||||
: parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0;
|
||||
if (!cssMode) {
|
||||
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';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
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';
|
||||
return;
|
||||
}
|
||||
@@ -963,7 +964,7 @@ export async function saveCalibration() {
|
||||
});
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast('Calibration saved', 'success');
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
@@ -972,13 +973,13 @@ export async function saveCalibration() {
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||||
error.textContent = t('calibration.error.save_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to save calibration:', err);
|
||||
error.textContent = 'Failed to save calibration';
|
||||
error.textContent = t('calibration.error.save_failed');
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ async function _loadAudioSources() {
|
||||
if (!select) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-sources');
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const sources = data.sources || [];
|
||||
select.innerHTML = sources.map(s => {
|
||||
@@ -905,7 +905,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
if (error.isAuth) return;
|
||||
showToast('Failed to delete color strip source', 'error');
|
||||
showToast(t('color_strip.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,10 +198,16 @@ function _updateRunningMetrics(enrichedRunning) {
|
||||
if (chart) {
|
||||
const actualH = _fpsHistory[target.id] || [];
|
||||
const currentH = _fpsCurrentHistory[target.id] || [];
|
||||
chart.data.datasets[0].data = [...actualH];
|
||||
chart.data.datasets[1].data = [...currentH];
|
||||
chart.data.labels = actualH.map(() => '');
|
||||
chart.update();
|
||||
// Mutate in-place to avoid array copies
|
||||
const ds0 = chart.data.datasets[0].data;
|
||||
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.update('none');
|
||||
}
|
||||
|
||||
// Refresh uptime base for interpolation
|
||||
@@ -366,11 +372,14 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
setTabRefreshing('dashboard-content', true);
|
||||
|
||||
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('/profiles').catch(() => null),
|
||||
fetchWithAuth('/devices').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();
|
||||
@@ -384,6 +393,9 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
const cssSourceMap = {};
|
||||
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)
|
||||
let dynamicHtml = '';
|
||||
let runningIds = [];
|
||||
@@ -392,12 +404,6 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
if (targets.length === 0 && profiles.length === 0) {
|
||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
} 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 => ({
|
||||
...target,
|
||||
state: allStates[target.id] || {},
|
||||
@@ -712,7 +718,7 @@ export async function dashboardToggleProfile(profileId, enable) {
|
||||
}
|
||||
} catch (error) {
|
||||
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();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||||
showToast(t('dashboard.error.start_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
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();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||||
showToast(t('dashboard.error.stop_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
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();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed: ${error.detail}`, 'error');
|
||||
showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast('Failed to toggle auto-start', 'error');
|
||||
showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
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 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 =>
|
||||
fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
|
||||
));
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast('Failed to stop all targets', 'error');
|
||||
showToast(t('dashboard.error.stop_all'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ export async function handleAddDevice(event) {
|
||||
}
|
||||
|
||||
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';
|
||||
return;
|
||||
}
|
||||
@@ -351,7 +351,7 @@ export async function handleAddDevice(event) {
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Device added successfully:', result);
|
||||
showToast('Device added successfully', 'success');
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
addDeviceModal.forceClose();
|
||||
// Use window.* to avoid circular imports
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
@@ -365,12 +365,12 @@ export async function handleAddDevice(event) {
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
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';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
console.error('Failed to add device:', err);
|
||||
showToast('Failed to add device', 'error');
|
||||
showToast(t('device_discovery.error.add_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function turnOffDevice(deviceId) {
|
||||
}
|
||||
} catch (error) {
|
||||
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',
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast('Device removed', 'success');
|
||||
showToast(t('device.removed'), 'success');
|
||||
window.loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to remove: ${error.detail}`, 'error');
|
||||
showToast(t('device.error.remove_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
try {
|
||||
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 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-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 urlGroup = document.getElementById('settings-url-group');
|
||||
@@ -242,7 +242,7 @@ export async function showSettings(deviceId) {
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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();
|
||||
|
||||
if (!name || !url) {
|
||||
settingsModal.showError('Please fill in all fields correctly');
|
||||
settingsModal.showError(t('device.error.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
@@ -283,7 +287,7 @@ export async function saveDeviceSettings() {
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
const errorData = await deviceResponse.json();
|
||||
settingsModal.showError(`Failed to update device: ${errorData.detail}`);
|
||||
settingsModal.showError(t('device.error.update'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -293,7 +297,7 @@ export async function saveDeviceSettings() {
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
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);
|
||||
updateDeviceBrightness(deviceId, bri);
|
||||
try {
|
||||
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ brightness: bri })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
showToast(t('device.error.brightness'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update brightness:', err);
|
||||
showToast('Failed to update brightness', 'error');
|
||||
if (err.isAuth) return;
|
||||
showToast(t('device.error.brightness'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +329,7 @@ export async function fetchDeviceBrightness(deviceId) {
|
||||
if (_brightnessFetchInFlight.has(deviceId)) return;
|
||||
_brightnessFetchInFlight.add(deviceId);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
updateDeviceBrightness(deviceId, data.brightness);
|
||||
@@ -398,9 +402,7 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
||||
|
||||
try {
|
||||
const discoverType = settingsModal.deviceType || 'adalight';
|
||||
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const devices = data.devices || [];
|
||||
|
||||
@@ -505,7 +505,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
|
||||
} catch (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) {
|
||||
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');
|
||||
const target = await resp.json();
|
||||
showKCEditor(null, target);
|
||||
} catch (error) {
|
||||
console.error('Failed to clone KC target:', error);
|
||||
showToast('Failed to clone key colors target', 'error');
|
||||
if (error.isAuth) return;
|
||||
showToast(t('kc_target.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,11 +613,11 @@ export async function deleteKCTarget(targetId) {
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
showToast(t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast('Failed to delete key colors target', 'error');
|
||||
showToast(t('kc_target.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
|
||||
} catch (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) {
|
||||
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');
|
||||
const tmpl = await resp.json();
|
||||
showPatternTemplateEditor(null, tmpl);
|
||||
} catch (error) {
|
||||
console.error('Failed to clone pattern template:', error);
|
||||
showToast('Failed to clone pattern template', 'error');
|
||||
if (error.isAuth) return;
|
||||
showToast(t('pattern.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +216,11 @@ export async function deletePatternTemplate(templateId) {
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
showToast(t('pattern.error.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
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) {
|
||||
console.error('Failed to capture background:', error);
|
||||
showToast('Failed to capture background', 'error');
|
||||
showToast(t('pattern.error.capture_bg_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +144,13 @@ function _pushSample(key, value) {
|
||||
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
|
||||
const chart = _charts[key];
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0].data = [..._history[key]];
|
||||
chart.data.labels = _history[key].map(() => '');
|
||||
chart.update();
|
||||
const ds = chart.data.datasets[0].data;
|
||||
ds.length = 0;
|
||||
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() {
|
||||
|
||||
@@ -242,8 +242,11 @@ function restoreCaptureDuration() {
|
||||
}
|
||||
|
||||
export async function showTestTemplateModal(templateId) {
|
||||
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
|
||||
const template = templates.templates.find(t => t.id === templateId);
|
||||
try {
|
||||
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) {
|
||||
showToast(t('templates.error.load'), 'error');
|
||||
@@ -255,6 +258,10 @@ export async function showTestTemplateModal(templateId) {
|
||||
restoreCaptureDuration();
|
||||
|
||||
testTemplateModal.open();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('templates.error.load'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTestTemplateModal() {
|
||||
@@ -871,7 +878,7 @@ export async function cloneAudioTemplate(templateId) {
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
if (error.isAuth) return;
|
||||
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) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone PP template:', error);
|
||||
showToast('Failed to clone postprocessing template', 'error');
|
||||
showToast(t('stream.error.clone_pp_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,9 +113,15 @@ function _updateTargetFpsChart(targetId, fpsTarget) {
|
||||
if (!chart) return;
|
||||
const actualH = _targetFpsHistory[targetId] || [];
|
||||
const currentH = _targetFpsCurrentHistory[targetId] || [];
|
||||
chart.data.labels = actualH.map(() => '');
|
||||
chart.data.datasets[0].data = [...actualH];
|
||||
chart.data.datasets[1].data = [...currentH];
|
||||
// Mutate in-place to avoid array copies
|
||||
const ds0 = chart.data.datasets[0].data;
|
||||
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.update('none');
|
||||
}
|
||||
@@ -352,7 +358,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
|
||||
} catch (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');
|
||||
} else {
|
||||
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');
|
||||
} else {
|
||||
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);
|
||||
} catch (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();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed: ${error.detail}`, 'error');
|
||||
showToast(t('target.error.autostart_toggle_failed'), 'error');
|
||||
}
|
||||
} catch (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');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
showToast(t('target.error.delete_failed'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,10 +239,11 @@ export async function saveValueSource() {
|
||||
export async function editValueSource(sourceId) {
|
||||
try {
|
||||
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();
|
||||
await showValueSourceModal(data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -252,7 +253,7 @@ export async function editValueSource(sourceId) {
|
||||
export async function cloneValueSource(sourceId) {
|
||||
try {
|
||||
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();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
|
||||
@@ -992,5 +992,54 @@
|
||||
"settings.saved_backups.download": "Download",
|
||||
"settings.saved_backups.delete": "Delete",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -992,5 +992,54 @@
|
||||
"settings.saved_backups.download": "Скачать",
|
||||
"settings.saved_backups.delete": "Удалить",
|
||||
"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 закрыть"
|
||||
}
|
||||
|
||||
@@ -992,5 +992,54 @@
|
||||
"settings.saved_backups.download": "下载",
|
||||
"settings.saved_backups.delete": "删除",
|
||||
"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 关闭"
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<div class="cp-dialog">
|
||||
<input id="cp-input" class="cp-input" placeholder="Search..." autocomplete="off">
|
||||
<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>
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
// Re-derive accent text variant for the new theme
|
||||
const accent = localStorage.getItem('accentColor');
|
||||
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
|
||||
@@ -247,7 +247,7 @@
|
||||
if (native) native.value = hex;
|
||||
localStorage.setItem('accentColor', 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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- 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-header">
|
||||
<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">×</button>
|
||||
<button class="modal-close-btn" onclick="closeAudioSourceModal()" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="audio-source-form" onsubmit="return false;">
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<p id="confirm-message" class="modal-description"></p>
|
||||
</div>
|
||||
<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-yes-btn" onclick="closeConfirmModal(true)">Yes</button>
|
||||
<button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button>
|
||||
<button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,5 +75,8 @@
|
||||
|
||||
<div id="settings-error" class="error-message" style="display:none;"></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">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- 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-header">
|
||||
<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">×</button>
|
||||
<button class="modal-close-btn" onclick="closeValueSourceModal()" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="value-source-form" onsubmit="return false;">
|
||||
|
||||
Reference in New Issue
Block a user