Comprehensive WebUI review: 41 UX/feature/CSS improvements
Safety & Correctness: - Add confirmation dialogs to Stop All, turnOffDevice - i18n confirm dialog (title, yes, no buttons) - Fix duplicate tutorial-overlay ID - Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg) - Fix toast z-index conflict with confirm dialog (2500 → 3000) UX Consistency: - Add backdrop-close to test modals - Add device clone feature (only entity without it) - Add sync clocks to command palette - Replace 20+ hardcoded accent colors with CSS vars/color-mix() - Remove dead .badge duplicate from components.css - Make calibration elements keyboard-accessible (div → button) - Add aria-labels to color picker swatches - Fix pattern canvas mobile horizontal scroll - Fix graph editor mobile bottom clipping Polish: - Add empty-state messages to all CardSection instances - Convert 21 px font-sizes to rem - Add scroll-behavior: smooth with reduced-motion override - Add @media print styles - Add :focus-visible to 4 missing interactive elements - Fix settings modal close label (Cancel → Close) - Fix api-key submit button i18n New Features: - Command palette actions: start/stop targets, activate scenes, enable/disable - Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop) - OS notification history viewer modal - Scene "used by" automation reference count on cards - Clock elapsed time display on Streams tab cards - Device "last seen" relative timestamp on cards - Audio device refresh button in edit modal - Composite layer drag-to-reorder - MQTT settings panel (broker config with JSON persistence) - WebSocket log viewer with level filtering and ring buffer - Runtime log-level adjustment (GET/PUT endpoints + settings UI) - Animated value source waveform canvas preview - Gradient custom preset save/delete (localStorage) - API key read-only display in settings - Backup metadata (file size, auto/manual badges) - Server restart button with confirm + overlay - Partial config export/import per entity type - Progressive disclosure in target editor (Advanced section) CSS Architecture: - Define radius scale tokens (--radius-sm/md/lg/pill) - Scope .cs-filter selectors to remove 7 !important overrides - Consolidate duplicate toggle switch (filter-list → settings-toggle) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
61
TODO.md
61
TODO.md
@@ -66,3 +66,64 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
||||
- [x] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
|
||||
- [x] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
|
||||
|
||||
## WebUI Review (2026-03-16)
|
||||
|
||||
### Critical (Safety & Correctness)
|
||||
- [x] `P1` **"Stop All" buttons need confirmation** — dashboard, LED targets, KC targets
|
||||
- [x] `P1` **`turnOffDevice()` needs confirmation**
|
||||
- [x] `P1` **Confirm dialog i18n** — added data-i18n to title/buttons
|
||||
- [x] `P1` **Duplicate `id="tutorial-overlay"`** — renamed to calibration-tutorial-overlay
|
||||
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
|
||||
- [x] `P1` **Toast z-index conflict** — toast now 3000
|
||||
|
||||
### UX Consistency
|
||||
- [x] `P1` **Test modals backdrop-close** — added setupBackdropClose
|
||||
- [x] `P1` **Devices clone** — added cloneDevice with full field prefill
|
||||
- [x] `P1` **Sync clocks in command palette** — added to _responseKeys + _buildItems
|
||||
- [x] `P2` **Hardcoded accent colors** — 20+ replacements using color-mix() and CSS vars
|
||||
- [x] `P2` **Duplicate `.badge` definition** — removed dead code from components.css
|
||||
- [x] `P2` **Calibration elements keyboard-accessible** — changed div to button
|
||||
- [x] `P2` **Color-picker swatch aria-labels** — added aria-label with hex value
|
||||
- [x] `P2` **Pattern canvas mobile scroll** — added min-width: 0 override in mobile.css
|
||||
- [x] `P2` **Graph editor mobile bottom clipping** — adjusted height in mobile.css
|
||||
|
||||
### Low Priority Polish
|
||||
- [x] `P3` **Empty-state illustrations/onboarding** — CardSection emptyKey with per-entity messages
|
||||
- [x] `P3` **api-key-modal submit title i18n**
|
||||
- [x] `P3` **Settings modal close labeled "Cancel" → "Close"**
|
||||
- [x] `P3` **Inconsistent px vs rem font sizes** — 21 conversions across streams/modal/cards CSS
|
||||
- [x] `P3` **scroll-behavior: smooth** — added with reduced-motion override
|
||||
- [x] `P3` **Reduce !important usage** — scoped .cs-filter selectors
|
||||
- [x] `P3` **@media print styles** — theme reset + hide nav
|
||||
- [x] `P3` **:focus-visible on interactive elements** — added 4 missing selectors
|
||||
- [x] `P3` **iOS Safari modal scroll-position jump** — already implemented in ui.js lockBody/unlockBody
|
||||
|
||||
### New Features
|
||||
- [x] `P1` **Command palette actions** — start/stop targets, activate scenes, enable/disable automations
|
||||
- [x] `P1` **Bulk start/stop API** — POST /output-targets/bulk/start and /bulk/stop
|
||||
- [x] `P1` **OS notification history viewer** — modal with app name, timestamp, fired/filtered badges
|
||||
- [x] `P1` **Scene "used by" reference count** — badge on card with automation count
|
||||
- [x] `P1` **Clock elapsed time on cards** — shows formatted elapsed time
|
||||
- [x] `P1` **Device "last seen" timestamp** — relative time with full ISO in title
|
||||
- [x] `P2` **Audio device refresh in modal** — refresh button next to device dropdown
|
||||
- [x] `P2` **Composite layer reorder** — drag handles with pointer-based reorder
|
||||
- [x] `P2` **MQTT settings panel** — config form with enabled/host/port/auth/topic, JSON persistence
|
||||
- [x] `P2` **Log viewer** — WebSocket broadcaster with ring buffer, level-filtered UI in settings
|
||||
- [x] `P2` **Animated value source waveform preview** — canvas drawing of sine/triangle/sawtooth/square
|
||||
- [x] `P2` **Gradient custom preset save** — localStorage-backed custom presets with save/delete
|
||||
- [x] `P2` **API key management UI** — read-only display of key labels with masked values
|
||||
- [x] `P2` **Backup metadata** — file size, auto/manual badge
|
||||
- [x] `P2` **Server restart button** — in settings with confirm dialog + restart overlay
|
||||
- [x] `P2` **Partial config export/import** — per-store export/import with merge option
|
||||
- [x] `P3` **Audio spectrum visualizer** — already fully implemented
|
||||
- [ ] `P3` **Hue bridge pairing flow** — requires physical Hue bridge hardware
|
||||
- [x] `P3` **Runtime log-level adjustment** — GET/PUT endpoints + settings dropdown
|
||||
- [x] `P3` **Progressive disclosure in target editor** — advanced section collapsed by default
|
||||
|
||||
### CSS Architecture
|
||||
- [x] `P1` **Define missing CSS variables** — --radius, --text-primary, --hover-bg, --input-bg
|
||||
- [x] `P2` **Define radius scale** — --radius-sm/md/lg/pill tokens, migrated key selectors
|
||||
- [x] `P2` **Scope generic input selector** — .cs-filter boosted specificity, 7 !important removed
|
||||
- [x] `P2` **Consolidate duplicate toggle switch** — filter-list uses settings-toggle
|
||||
- [x] `P2` **Replace hardcoded accent colors** — 20+ values → CSS vars with color-mix()
|
||||
|
||||
@@ -22,6 +22,8 @@ from wled_controller.api.dependencies import (
|
||||
get_template_store,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
BulkTargetRequest,
|
||||
BulkTargetResponse,
|
||||
ExtractedColorResponse,
|
||||
KCTestRectangleResponse,
|
||||
KCTestResponse,
|
||||
@@ -373,6 +375,64 @@ async def delete_target(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_start_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
|
||||
started: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
await manager.start_processing(target_id)
|
||||
started.append(target_id)
|
||||
logger.info(f"Bulk start: started processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
errors[target_id] = msg
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(started=started, errors=errors)
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_stop_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
||||
stopped: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
stopped.append(target_id)
|
||||
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(stopped=stopped, errors=errors)
|
||||
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -12,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -44,6 +45,10 @@ from wled_controller.api.schemas.system import (
|
||||
DisplayListResponse,
|
||||
GpuInfo,
|
||||
HealthResponse,
|
||||
LogLevelRequest,
|
||||
LogLevelResponse,
|
||||
MQTTSettingsRequest,
|
||||
MQTTSettingsResponse,
|
||||
PerformanceResponse,
|
||||
ProcessListResponse,
|
||||
RestoreResponse,
|
||||
@@ -331,6 +336,125 @@ def _schedule_restart() -> None:
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
|
||||
@router.get("/api/v1/system/api-keys", tags=["System"])
|
||||
def list_api_keys(_: AuthRequired):
|
||||
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
||||
config = get_config()
|
||||
keys = [
|
||||
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
|
||||
for label, key in config.auth.api_keys.items()
|
||||
]
|
||||
return {"keys": keys, "count": len(keys)}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
|
||||
def export_store(store_key: str, _: AuthRequired):
|
||||
"""Download a single entity store as a JSON file."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
export = {
|
||||
"meta": {
|
||||
"format": "ledgrab-partial-export",
|
||||
"format_version": 1,
|
||||
"store_key": store_key,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
},
|
||||
"store": data,
|
||||
}
|
||||
content = json.dumps(export, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-{store_key}-{timestamp}.json"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
|
||||
async def import_store(
|
||||
store_key: str,
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
|
||||
):
|
||||
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
|
||||
# Support both full-backup format and partial-export format
|
||||
if "stores" in payload and isinstance(payload.get("meta"), dict):
|
||||
# Full backup: extract the specific store
|
||||
if payload["meta"].get("format") not in ("ledgrab-backup",):
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
stores = payload.get("stores", {})
|
||||
if store_key not in stores:
|
||||
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
|
||||
incoming = stores[store_key]
|
||||
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
|
||||
# Partial export format
|
||||
if payload["meta"].get("store_key") != store_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
|
||||
)
|
||||
incoming = payload.get("store", {})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
|
||||
if not isinstance(incoming, dict):
|
||||
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
|
||||
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
|
||||
def _write():
|
||||
if merge and file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if isinstance(existing, dict):
|
||||
existing.update(incoming)
|
||||
atomic_write_json(file_path, existing)
|
||||
return len(existing)
|
||||
atomic_write_json(file_path, incoming)
|
||||
return len(incoming)
|
||||
|
||||
count = await asyncio.to_thread(_write)
|
||||
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||
_schedule_restart()
|
||||
return {
|
||||
"status": "imported",
|
||||
"store_key": store_key,
|
||||
"entries": count,
|
||||
"merge": merge,
|
||||
"restart_scheduled": True,
|
||||
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backup", tags=["System"])
|
||||
def backup_config(_: AuthRequired):
|
||||
"""Download all configuration as a single JSON backup file."""
|
||||
@@ -366,6 +490,13 @@ def backup_config(_: AuthRequired):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restart", tags=["System"])
|
||||
def restart_server(_: AuthRequired):
|
||||
"""Schedule a server restart and return immediately."""
|
||||
_schedule_restart()
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||
async def restore_config(
|
||||
_: AuthRequired,
|
||||
@@ -532,6 +663,160 @@ async def delete_saved_backup(
|
||||
return {"status": "deleted", "filename": filename}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MQTT settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MQTT_SETTINGS_FILE: Path | None = None
|
||||
|
||||
|
||||
def _get_mqtt_settings_path() -> Path:
|
||||
global _MQTT_SETTINGS_FILE
|
||||
if _MQTT_SETTINGS_FILE is None:
|
||||
cfg = get_config()
|
||||
# Derive the data directory from any known storage file path
|
||||
data_dir = Path(cfg.storage.devices_file).parent
|
||||
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
|
||||
return _MQTT_SETTINGS_FILE
|
||||
|
||||
|
||||
def _load_mqtt_settings() -> dict:
|
||||
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
|
||||
cfg = get_config()
|
||||
defaults = {
|
||||
"enabled": cfg.mqtt.enabled,
|
||||
"broker_host": cfg.mqtt.broker_host,
|
||||
"broker_port": cfg.mqtt.broker_port,
|
||||
"username": cfg.mqtt.username,
|
||||
"password": cfg.mqtt.password,
|
||||
"client_id": cfg.mqtt.client_id,
|
||||
"base_topic": cfg.mqtt.base_topic,
|
||||
}
|
||||
path = _get_mqtt_settings_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
overrides = json.load(f)
|
||||
defaults.update(overrides)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load MQTT settings override file: {e}")
|
||||
return defaults
|
||||
|
||||
|
||||
def _save_mqtt_settings(settings: dict) -> None:
|
||||
"""Persist MQTT settings to the JSON override file."""
|
||||
from wled_controller.utils import atomic_write_json
|
||||
atomic_write_json(_get_mqtt_settings_path(), settings)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/mqtt/settings",
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_mqtt_settings(_: AuthRequired):
|
||||
"""Get current MQTT broker settings. Password is masked."""
|
||||
s = _load_mqtt_settings()
|
||||
return MQTTSettingsResponse(
|
||||
enabled=s["enabled"],
|
||||
broker_host=s["broker_host"],
|
||||
broker_port=s["broker_port"],
|
||||
username=s["username"],
|
||||
password_set=bool(s.get("password")),
|
||||
client_id=s["client_id"],
|
||||
base_topic=s["base_topic"],
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/mqtt/settings",
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
|
||||
current = _load_mqtt_settings()
|
||||
|
||||
# If caller sends an empty password, keep the existing one
|
||||
password = body.password if body.password else current.get("password", "")
|
||||
|
||||
new_settings = {
|
||||
"enabled": body.enabled,
|
||||
"broker_host": body.broker_host,
|
||||
"broker_port": body.broker_port,
|
||||
"username": body.username,
|
||||
"password": password,
|
||||
"client_id": body.client_id,
|
||||
"base_topic": body.base_topic,
|
||||
}
|
||||
_save_mqtt_settings(new_settings)
|
||||
logger.info("MQTT settings updated")
|
||||
|
||||
return MQTTSettingsResponse(
|
||||
enabled=new_settings["enabled"],
|
||||
broker_host=new_settings["broker_host"],
|
||||
broker_port=new_settings["broker_port"],
|
||||
username=new_settings["username"],
|
||||
password_set=bool(new_settings["password"]),
|
||||
client_id=new_settings["client_id"],
|
||||
base_topic=new_settings["base_topic"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live log viewer WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.websocket("/api/v1/system/logs/ws")
|
||||
async def logs_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket that streams server log lines in real time.
|
||||
|
||||
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
|
||||
lines as individual text messages, then pushes new lines as they appear.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
from wled_controller.utils import log_broadcaster
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# Ensure the broadcaster knows the event loop (may be first connection)
|
||||
log_broadcaster.ensure_loop()
|
||||
|
||||
# Subscribe *before* reading the backlog so no lines slip through
|
||||
queue = log_broadcaster.subscribe()
|
||||
|
||||
try:
|
||||
# Send backlog first
|
||||
for line in log_broadcaster.get_backlog():
|
||||
await websocket.send_text(line)
|
||||
|
||||
# Stream new lines
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
await websocket.send_text(line)
|
||||
except asyncio.TimeoutError:
|
||||
# Send a keepalive ping so the connection stays alive
|
||||
try:
|
||||
await websocket.send_text("")
|
||||
except Exception:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
log_broadcaster.unsubscribe(queue)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADB helpers (for Android / scrcpy engine)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -601,3 +886,34 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||
|
||||
|
||||
# ─── Log level ─────────────────────────────────────────────────
|
||||
|
||||
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||
async def get_log_level(_: AuthRequired):
|
||||
"""Get the current root logger log level."""
|
||||
level_int = logging.getLogger().getEffectiveLevel()
|
||||
return LogLevelResponse(level=logging.getLevelName(level_int))
|
||||
|
||||
|
||||
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
|
||||
"""Change the root logger log level at runtime (no server restart required)."""
|
||||
level_name = body.level.upper()
|
||||
if level_name not in _VALID_LOG_LEVELS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
|
||||
)
|
||||
level_int = getattr(logging, level_name)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level_int)
|
||||
# Also update all handlers so they actually emit at the new level
|
||||
for handler in root.handlers:
|
||||
handler.setLevel(level_int)
|
||||
logger.info("Log level changed to %s", level_name)
|
||||
return LogLevelResponse(level=level_name)
|
||||
|
||||
@@ -177,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
|
||||
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
||||
|
||||
|
||||
class BulkTargetRequest(BaseModel):
|
||||
"""Request body for bulk start/stop operations."""
|
||||
|
||||
ids: List[str] = Field(description="List of target IDs to operate on")
|
||||
|
||||
|
||||
class BulkTargetResponse(BaseModel):
|
||||
"""Response for bulk start/stop operations."""
|
||||
|
||||
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
|
||||
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
|
||||
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
|
||||
|
||||
|
||||
class KCTestRectangleResponse(BaseModel):
|
||||
"""A rectangle with its extracted color from a KC test."""
|
||||
|
||||
|
||||
@@ -115,3 +115,43 @@ class BackupListResponse(BaseModel):
|
||||
|
||||
backups: List[BackupFileInfo]
|
||||
count: int
|
||||
|
||||
|
||||
# ─── MQTT schemas ──────────────────────────────────────────────
|
||||
|
||||
class MQTTSettingsResponse(BaseModel):
|
||||
"""MQTT broker settings response (password is masked)."""
|
||||
|
||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||
username: str = Field(description="MQTT username (empty = anonymous)")
|
||||
password_set: bool = Field(description="Whether a password is configured")
|
||||
client_id: str = Field(description="MQTT client ID")
|
||||
base_topic: str = Field(description="Base topic prefix")
|
||||
|
||||
|
||||
class MQTTSettingsRequest(BaseModel):
|
||||
"""MQTT broker settings update request."""
|
||||
|
||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
||||
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
|
||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||
|
||||
|
||||
# ─── Log level schemas ─────────────────────────────────────────
|
||||
|
||||
class LogLevelResponse(BaseModel):
|
||||
"""Current log level response."""
|
||||
|
||||
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||
|
||||
|
||||
class LogLevelRequest(BaseModel):
|
||||
"""Request to change the log level."""
|
||||
|
||||
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||
|
||||
@@ -39,10 +39,11 @@ from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
||||
from wled_controller.api.routes.system import STORE_MAP
|
||||
from wled_controller.utils import setup_logging, get_logger
|
||||
from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
|
||||
|
||||
# Initialize logging
|
||||
setup_logging()
|
||||
install_broadcast_handler()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Get configuration
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
--warning-color: #ff9800;
|
||||
--info-color: #2196F3;
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-pill: 100px;
|
||||
}
|
||||
|
||||
/* ── SVG icon base ── */
|
||||
@@ -34,6 +39,7 @@
|
||||
--bg-secondary: #242424;
|
||||
--card-bg: #2d2d2d;
|
||||
--text-color: #e0e0e0;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #999;
|
||||
--text-muted: #777;
|
||||
--border-color: #404040;
|
||||
@@ -41,6 +47,8 @@
|
||||
--primary-text-color: #66bb6a;
|
||||
--success-color: #28a745;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--input-bg: #1a1a2e;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -50,6 +58,7 @@
|
||||
--bg-secondary: #eee;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333333;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666;
|
||||
--text-muted: #999;
|
||||
--border-color: #e0e0e0;
|
||||
@@ -57,6 +66,8 @@
|
||||
--primary-text-color: #3d8b40;
|
||||
--success-color: #2e7d32;
|
||||
--shadow-color: rgba(0, 0, 0, 0.12);
|
||||
--hover-bg: rgba(0, 0, 0, 0.05);
|
||||
--input-bg: #f0f0f0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -69,6 +80,7 @@ body {
|
||||
html {
|
||||
background: var(--bg-color);
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -184,4 +196,16 @@ header {
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
[data-theme] {
|
||||
--bg-color: #fff;
|
||||
--card-bg: #fff;
|
||||
--text-color: #000;
|
||||
--text-secondary: #333;
|
||||
--border-color: #ccc;
|
||||
}
|
||||
.tab-bar, .app-header, .scroll-to-top, .toast-container { display: none; }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ section {
|
||||
|
||||
.add-device-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(33, 150, 243, 0.05);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, transparent); /* --primary-color tint */
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ section {
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 20px 20px;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
@@ -243,7 +243,7 @@ section {
|
||||
.card-drag-placeholder {
|
||||
border: 2px dashed var(--primary-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(33, 150, 243, 0.04);
|
||||
background: color-mix(in srgb, var(--primary-color) 4%, transparent); /* --primary-color tint */
|
||||
min-height: 80px;
|
||||
transition: none;
|
||||
}
|
||||
@@ -378,7 +378,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
|
||||
.card-power-btn:hover {
|
||||
color: var(--primary-text-color);
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent); /* --primary-color tint */
|
||||
}
|
||||
|
||||
.card-remove-btn {
|
||||
@@ -401,7 +401,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
|
||||
.card-remove-btn:hover {
|
||||
color: var(--danger-color);
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -569,7 +569,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
}
|
||||
.zone-checkbox-list .zone-loading,
|
||||
.zone-checkbox-list .zone-error {
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -581,7 +581,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.zone-checkbox-item:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
|
||||
@@ -602,7 +602,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.zone-mode-option input[type="radio"] { margin: 0; }
|
||||
|
||||
@@ -629,7 +629,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
.display-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
@@ -676,7 +676,10 @@ body.cs-drag-active .card-drag-handle {
|
||||
|
||||
.layout-display.primary {
|
||||
border-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15), rgba(76, 175, 80, 0.05));
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--primary-color) 15%, transparent), /* --primary-color tint */
|
||||
color-mix(in srgb, var(--primary-color) 5%, transparent) /* --primary-color tint */
|
||||
);
|
||||
}
|
||||
|
||||
.layout-display.secondary {
|
||||
@@ -768,7 +771,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
margin: 0 0 15px 0;
|
||||
line-height: 1.5;
|
||||
padding: 8px 12px;
|
||||
background: rgba(33, 150, 243, 0.08);
|
||||
background: color-mix(in srgb, var(--info-color, #2196F3) 8%, transparent); /* --info-color tint */
|
||||
border-left: 3px solid var(--info-color, #2196F3);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
@@ -939,13 +942,13 @@ ul.section-tip li {
|
||||
transition: flex 0.3s ease;
|
||||
}
|
||||
|
||||
.timing-extract { background: #4CAF50; }
|
||||
.timing-extract { background: var(--primary-color); }
|
||||
.timing-map { background: #FF9800; }
|
||||
.timing-smooth { background: #2196F3; }
|
||||
.timing-smooth { background: var(--info-color, #2196F3); }
|
||||
.timing-send { background: #E91E63; }
|
||||
.timing-audio-read { background: #4CAF50; }
|
||||
.timing-audio-read { background: var(--primary-color); }
|
||||
.timing-audio-fft { background: #FF9800; }
|
||||
.timing-audio-render { background: #2196F3; }
|
||||
.timing-audio-render { background: var(--info-color, #2196F3); }
|
||||
|
||||
.timing-legend {
|
||||
display: flex;
|
||||
@@ -969,13 +972,13 @@ ul.section-tip li {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.timing-dot.timing-extract { background: #4CAF50; }
|
||||
.timing-dot.timing-extract { background: var(--primary-color); }
|
||||
.timing-dot.timing-map { background: #FF9800; }
|
||||
.timing-dot.timing-smooth { background: #2196F3; }
|
||||
.timing-dot.timing-smooth { background: var(--info-color, #2196F3); }
|
||||
.timing-dot.timing-send { background: #E91E63; }
|
||||
.timing-dot.timing-audio-read { background: #4CAF50; }
|
||||
.timing-dot.timing-audio-read { background: var(--primary-color); }
|
||||
.timing-dot.timing-audio-fft { background: #FF9800; }
|
||||
.timing-dot.timing-audio-render { background: #2196F3; }
|
||||
.timing-dot.timing-audio-render { background: var(--info-color, #2196F3); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.displays-grid,
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
.badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.processing {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
.badge.idle {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@@ -60,7 +38,7 @@
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
@@ -182,7 +160,7 @@ select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
@@ -351,7 +329,7 @@ input:-webkit-autofill:focus {
|
||||
.overlay-preview-img {
|
||||
max-width: 80vw;
|
||||
max-height: 50vh;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -369,13 +347,13 @@ input:-webkit-autofill:focus {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 2500;
|
||||
z-index: 3000;
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
min-width: 300px;
|
||||
text-align: center;
|
||||
@@ -424,7 +402,7 @@ input:-webkit-autofill:focus {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -439,7 +417,7 @@ input:-webkit-autofill:focus {
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-color);
|
||||
cursor: text;
|
||||
min-height: 38px;
|
||||
@@ -501,7 +479,7 @@ input:-webkit-autofill:focus {
|
||||
z-index: 1000;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
@@ -538,7 +516,11 @@ input:-webkit-autofill:focus {
|
||||
.dashboard-action-btn:focus-visible,
|
||||
.btn-expand-collapse:focus-visible,
|
||||
.btn-filter-action:focus-visible,
|
||||
.settings-toggle:focus-visible {
|
||||
.settings-toggle:focus-visible,
|
||||
.discovery-item:focus-visible,
|
||||
.tag-chip-remove:focus-visible,
|
||||
.cs-filter-reset:focus-visible,
|
||||
.graph-filter-clear:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -586,7 +568,7 @@ textarea:focus-visible {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
@@ -720,7 +702,7 @@ textarea:focus-visible {
|
||||
overflow-y: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 16px 48px var(--shadow-color);
|
||||
padding: 16px;
|
||||
opacity: 0;
|
||||
@@ -790,7 +772,7 @@ textarea:focus-visible {
|
||||
max-height: 60vh;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -878,7 +860,7 @@ textarea:focus-visible {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -190,12 +190,12 @@ h2 {
|
||||
|
||||
.health-dot.health-online {
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.6);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--primary-color) 60%, transparent); /* --primary-color glow */
|
||||
}
|
||||
|
||||
.health-dot.health-offline {
|
||||
background-color: var(--danger-color);
|
||||
box-shadow: 0 0 6px rgba(244, 67, 54, 0.6);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--danger-color) 60%, transparent); /* --danger-color glow */
|
||||
}
|
||||
|
||||
.health-dot.health-unknown {
|
||||
@@ -565,9 +565,23 @@ h2 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
background: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px #4caf50;
|
||||
box-shadow: 0 0 4px var(--primary-color);
|
||||
}
|
||||
|
||||
.cp-action-item .cp-icon {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cp-action-item .cp-detail {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.cp-action-item.cp-active .cp-detail {
|
||||
color: var(--primary-contrast);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cp-loading,
|
||||
|
||||
@@ -219,6 +219,11 @@
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
/* Graph editor: account for fixed bottom tab bar (~64px) */
|
||||
.graph-container {
|
||||
height: calc(100vh - var(--header-height, 60px) - 74px);
|
||||
}
|
||||
|
||||
/* ── Container ── */
|
||||
.container {
|
||||
padding: 8px;
|
||||
@@ -441,6 +446,9 @@
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* Pattern canvas — prevent horizontal overflow */
|
||||
.pattern-canvas-container { min-width: 0; width: 100%; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
.vs-test-value-large {
|
||||
font-size: 1.3em;
|
||||
color: #4caf50;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.vs-test-status {
|
||||
@@ -149,7 +149,7 @@
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -333,6 +333,38 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Log viewer ─────────────────────────────────────────────── */
|
||||
|
||||
.log-viewer-output {
|
||||
background: #0d0d0d;
|
||||
color: #d4d4d4;
|
||||
font-family: var(--font-mono, 'Consolas', 'Courier New', monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
/* Scroll performance */
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.log-viewer-output .log-line-error {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.log-viewer-output .log-line-warning {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.log-viewer-output .log-line-debug {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
/* LED count control */
|
||||
.css-test-led-control {
|
||||
display: flex;
|
||||
@@ -391,7 +423,7 @@
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: calc(100vh - 40px);
|
||||
@@ -562,8 +594,8 @@
|
||||
|
||||
.hint-toggle.active {
|
||||
opacity: 1;
|
||||
color: var(--primary-text-color, #4CAF50);
|
||||
border-color: var(--primary-color, #4CAF50);
|
||||
color: var(--primary-text-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
@@ -695,6 +727,17 @@
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.select-with-action {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-with-action select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fps-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
@@ -767,7 +810,7 @@
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
|
||||
border: 1px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
padding: 12px;
|
||||
@@ -805,7 +848,7 @@
|
||||
|
||||
.btn-display-picker:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 15%, transparent); /* --primary-color tint */
|
||||
}
|
||||
|
||||
.modal-content-wide {
|
||||
@@ -983,14 +1026,14 @@
|
||||
}
|
||||
|
||||
.layout-display-pickable:hover {
|
||||
box-shadow: 0 0 20px rgba(76, 175, 80, 0.4);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent); /* --primary-color glow */
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.layout-display-pickable.selected {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
|
||||
background: rgba(76, 175, 80, 0.12) !important;
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 50%, transparent); /* --primary-color glow */
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent) !important; /* --primary-color tint */
|
||||
}
|
||||
|
||||
/* ── Device picker list (cameras, scrcpy) ─────────────────────── */
|
||||
@@ -1133,6 +1176,33 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Custom gradient presets list ───────────────────────────── */
|
||||
|
||||
.custom-presets-list {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.custom-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
.custom-preset-name {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Color Cycle editor ──────────────────────────────────────── */
|
||||
|
||||
#color-cycle-colors-list {
|
||||
@@ -1211,6 +1281,71 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Notification history list ─────────────────────────────────── */
|
||||
|
||||
.notif-history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.notif-history-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notif-history-app {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-history-time {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-history-badges {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-history-badge {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.notif-history-badge--fired {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.notif-history-badge--filtered {
|
||||
background: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notif-history-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Composite layer editor ────────────────────────────────────── */
|
||||
|
||||
#composite-layers-list {
|
||||
@@ -1269,3 +1404,58 @@
|
||||
flex: 0 0 26px;
|
||||
}
|
||||
|
||||
/* ── Composite layer drag-to-reorder ── */
|
||||
|
||||
.composite-layer-drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: opacity 0.2s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composite-layer-item:hover .composite-layer-drag-handle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.composite-layer-drag-handle:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.composite-layer-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.composite-layer-drag-clone {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
will-change: top;
|
||||
}
|
||||
|
||||
.composite-layer-drag-placeholder {
|
||||
border: 2px dashed var(--primary-color);
|
||||
border-radius: 4px;
|
||||
background: rgba(33, 150, 243, 0.04);
|
||||
min-height: 42px;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
body.composite-layer-dragging .composite-layer-item {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body.composite-layer-dragging .composite-layer-drag-handle {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
.template-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
display: flex;
|
||||
@@ -92,20 +92,20 @@
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.template-config {
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -166,7 +166,7 @@
|
||||
|
||||
.template-no-config {
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--primary-text-color);
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
@@ -176,7 +176,7 @@
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.config-table td {
|
||||
@@ -246,7 +246,7 @@
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* PP Filter List in Template Modal */
|
||||
@@ -259,7 +259,7 @@
|
||||
|
||||
.pp-filter-empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border-color);
|
||||
@@ -294,13 +294,13 @@
|
||||
|
||||
.pp-filter-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pp-filter-card-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -360,7 +360,7 @@
|
||||
.pp-filter-option label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -375,55 +375,21 @@
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.pp-filter-option-bool label {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
/* Bool option row: label text on left, .settings-toggle on right */
|
||||
.pp-filter-option-bool {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.pp-filter-option-bool input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 18px;
|
||||
background: var(--border-color);
|
||||
border-radius: 9px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
order: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pp-filter-option-bool input[type="checkbox"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.pp-filter-option-bool input[type="checkbox"]:checked {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.pp-filter-option-bool input[type="checkbox"]:checked::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.pp-filter-option-bool span {
|
||||
order: 0;
|
||||
.pp-filter-option-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── PP filter drag-and-drop ── */
|
||||
@@ -432,7 +398,7 @@
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
@@ -492,7 +458,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pp-add-filter-btn {
|
||||
@@ -506,7 +472,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
@@ -526,7 +492,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
.template-test-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.test-results-container {
|
||||
@@ -550,7 +516,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
.test-performance-section h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -579,7 +545,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
@@ -602,7 +568,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Stream type badges */
|
||||
@@ -619,7 +585,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
/* Stream info panel in stream selector modal */
|
||||
.stream-info-panel {
|
||||
padding: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -792,23 +758,23 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-filter {
|
||||
.cs-filter-wrap .cs-filter {
|
||||
width: 100%;
|
||||
padding: 4px 26px 4px 10px !important;
|
||||
font-size: 0.78rem !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 14px !important;
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--text-color) !important;
|
||||
padding: 4px 26px 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
box-shadow: none !important;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, background 0.2s, width 0.2s;
|
||||
}
|
||||
|
||||
.cs-filter:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
background: var(--bg-color) !important;
|
||||
.cs-filter-wrap .cs-filter:focus {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cs-filter::placeholder {
|
||||
@@ -838,6 +804,22 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Empty state for CardSection */
|
||||
.cs-empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-empty-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.templates-grid {
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
import {
|
||||
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
|
||||
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
|
||||
cloneDevice,
|
||||
} from './features/device-discovery.js';
|
||||
import {
|
||||
loadTargetsTab, switchTargetSubTab,
|
||||
@@ -123,6 +124,10 @@ import {
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -130,6 +135,7 @@ import {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
@@ -138,6 +144,7 @@ import {
|
||||
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
||||
editAudioSource, cloneAudioSource, deleteAudioSource,
|
||||
testAudioSource, closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: value sources
|
||||
@@ -177,6 +184,11 @@ import { openCommandPalette, closeCommandPalette, initCommandPalette } from './c
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
loadLogLevel, setLogLevel,
|
||||
} from './features/settings.js';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
@@ -240,6 +252,7 @@ Object.assign(window, {
|
||||
loadDevices,
|
||||
updateSettingsBaudFpsHint,
|
||||
copyWsUrl,
|
||||
cloneDevice,
|
||||
|
||||
// dashboard
|
||||
loadDashboard,
|
||||
@@ -424,6 +437,10 @@ Object.assign(window, {
|
||||
mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -431,6 +448,7 @@ Object.assign(window, {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
|
||||
// audio sources
|
||||
@@ -442,6 +460,7 @@ Object.assign(window, {
|
||||
deleteAudioSource,
|
||||
testAudioSource,
|
||||
closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
|
||||
// value sources
|
||||
showValueSourceModal,
|
||||
@@ -504,7 +523,7 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
|
||||
// settings (backup / restore / auto-backup)
|
||||
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
downloadBackup,
|
||||
@@ -513,6 +532,17 @@ Object.assign(window, {
|
||||
restoreSavedBackup,
|
||||
downloadSavedBackup,
|
||||
deleteSavedBackup,
|
||||
restartServer,
|
||||
saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport,
|
||||
handlePartialImportFileSelected,
|
||||
connectLogViewer,
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
applyLogFilter,
|
||||
loadLogLevel,
|
||||
setLogLevel,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
|
||||
@@ -45,8 +45,9 @@ export class CardSection {
|
||||
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
|
||||
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
|
||||
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
|
||||
* @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items
|
||||
*/
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible }) {
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey }) {
|
||||
this.sectionKey = sectionKey;
|
||||
this.titleKey = titleKey;
|
||||
this.gridClass = gridClass;
|
||||
@@ -54,6 +55,7 @@ export class CardSection {
|
||||
this.keyAttr = keyAttr || '';
|
||||
this.headerExtra = headerExtra || '';
|
||||
this.collapsible = !!collapsible;
|
||||
this.emptyKey = emptyKey || '';
|
||||
this._filterValue = '';
|
||||
this._lastItems = null;
|
||||
this._dragState = null;
|
||||
@@ -85,6 +87,10 @@ export class CardSection {
|
||||
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
||||
: '';
|
||||
|
||||
const emptyState = (count === 0 && this.emptyKey)
|
||||
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
||||
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
|
||||
@@ -99,7 +105,7 @@ export class CardSection {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
|
||||
${cardsHtml}
|
||||
${emptyState}${cardsHtml}
|
||||
${addCard}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -205,6 +211,25 @@ export class CardSection {
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
if (countEl && !this._filterValue) countEl.textContent = items.length;
|
||||
|
||||
// Show/hide empty state
|
||||
if (this.emptyKey) {
|
||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
|
||||
if (items.length === 0) {
|
||||
if (!emptyEl) {
|
||||
emptyEl = document.createElement('div');
|
||||
emptyEl.className = 'cs-empty-state';
|
||||
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
|
||||
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
if (addCard) content.insertBefore(emptyEl, addCard);
|
||||
else content.appendChild(emptyEl);
|
||||
}
|
||||
emptyEl.style.display = '';
|
||||
} else if (emptyEl) {
|
||||
emptyEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
const added = new Set();
|
||||
|
||||
@@ -31,7 +31,7 @@ const PRESETS = [
|
||||
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) {
|
||||
const dots = PRESETS.map(c => {
|
||||
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
|
||||
}).join('');
|
||||
|
||||
const resetBtn = showReset
|
||||
|
||||
@@ -8,10 +8,11 @@ import { navigateToCard } from './navigation.js';
|
||||
import {
|
||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
|
||||
} from './icons.js';
|
||||
import { getCardColor } from './card-colors.js';
|
||||
import { graphNavigateToNode } from '../features/graph-editor.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
let _isOpen = false;
|
||||
let _items = [];
|
||||
@@ -33,7 +34,7 @@ function _mapEntities(data, mapFn) {
|
||||
}
|
||||
|
||||
function _buildItems(results, states = {}) {
|
||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates] = results;
|
||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
|
||||
const items = [];
|
||||
|
||||
_mapEntities(devices, d => items.push({
|
||||
@@ -54,6 +55,26 @@ function _buildItems(results, states = {}) {
|
||||
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
||||
});
|
||||
}
|
||||
// Action items: start or stop
|
||||
if (running) {
|
||||
items.push({
|
||||
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('device.stopped'), 'success');
|
||||
else showToast(t('target.error.stop_failed'), 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('device.started'), 'success');
|
||||
else showToast(t('target.error.start_failed'), 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_mapEntities(css, c => items.push({
|
||||
@@ -61,10 +82,31 @@ function _buildItems(results, states = {}) {
|
||||
nav: ['streams', 'color_strip', 'color-strips', 'data-css-id', c.id],
|
||||
}));
|
||||
|
||||
_mapEntities(automations, a => items.push({
|
||||
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
||||
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
||||
}));
|
||||
_mapEntities(automations, a => {
|
||||
items.push({
|
||||
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
||||
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
||||
});
|
||||
if (a.enabled) {
|
||||
items.push({
|
||||
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('search.action.disable') + ': ' + a.name, 'success');
|
||||
else showToast(t('search.action.disable') + ' failed', 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('search.action.enable') + ': ' + a.name, 'success');
|
||||
else showToast(t('search.action.enable') + ' failed', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_mapEntities(capTempl, ct => items.push({
|
||||
name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: ICON_CAPTURE_TEMPLATE,
|
||||
@@ -102,16 +144,31 @@ function _buildItems(results, states = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
_mapEntities(scenePresets, sp => items.push({
|
||||
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
|
||||
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
|
||||
}));
|
||||
_mapEntities(scenePresets, sp => {
|
||||
items.push({
|
||||
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
|
||||
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
|
||||
});
|
||||
items.push({
|
||||
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('scenes.activated'), 'success');
|
||||
else showToast(t('scenes.error.activate_failed'), 'error');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_mapEntities(csptTemplates, ct => items.push({
|
||||
name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT,
|
||||
nav: ['streams', 'css_processing', 'cspt-templates', 'data-cspt-id', ct.id],
|
||||
}));
|
||||
|
||||
_mapEntities(syncClocks, sc => items.push({
|
||||
name: sc.name, detail: sc.is_running ? 'running' : '', group: 'sync_clocks', icon: ICON_CLOCK,
|
||||
nav: ['streams', 'sync', 'sync-clocks', 'data-id', sc.id],
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -129,6 +186,7 @@ const _responseKeys = [
|
||||
['/picture-sources', 'streams'],
|
||||
['/scene-presets', 'presets'],
|
||||
['/color-strip-processing-templates', 'templates'],
|
||||
['/sync-clocks', 'clocks'],
|
||||
];
|
||||
|
||||
async function _fetchAllEntities() {
|
||||
@@ -149,9 +207,10 @@ async function _fetchAllEntities() {
|
||||
// ─── Group ordering ───
|
||||
|
||||
const _groupOrder = [
|
||||
'actions',
|
||||
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
|
||||
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||
'audio', 'value', 'scenes',
|
||||
'audio', 'value', 'scenes', 'sync_clocks',
|
||||
];
|
||||
|
||||
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
|
||||
@@ -204,9 +263,11 @@ function _render() {
|
||||
html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`;
|
||||
for (const item of items) {
|
||||
const active = idx === _selectedIdx ? ' cp-active' : '';
|
||||
const color = getCardColor(item.nav[4]);
|
||||
const entityId = item.nav ? item.nav[4] : null;
|
||||
const color = entityId ? getCardColor(entityId) : null;
|
||||
const colorStyle = color ? ` style="border-left:3px solid ${color}"` : '';
|
||||
html += `<div class="cp-result${active}" data-cp-idx="${idx}"${colorStyle}>` +
|
||||
const actionClass = item.action ? ' cp-action-item' : '';
|
||||
html += `<div class="cp-result${active}${actionClass}" data-cp-idx="${idx}"${colorStyle}>` +
|
||||
`<span class="cp-icon">${item.icon}</span>` +
|
||||
`<span class="cp-name">${escapeHtml(item.name)}</span>` +
|
||||
(item.running ? '<span class="cp-running"></span>' : '') +
|
||||
@@ -307,6 +368,12 @@ function _selectCurrent() {
|
||||
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
|
||||
const item = _filtered[_selectedIdx];
|
||||
closeCommandPalette();
|
||||
if (item.action) {
|
||||
item.action().catch(err => {
|
||||
if (!err.isAuth) showToast(err.message || 'Action failed', 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If graph tab is active, navigate to graph node instead of card
|
||||
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
|
||||
if (graphTabActive) {
|
||||
|
||||
@@ -148,10 +148,11 @@ export class FilterListManager {
|
||||
if (opt.type === 'bool') {
|
||||
const checked = currentVal === true || currentVal === 'true';
|
||||
html += `<div class="pp-filter-option pp-filter-option-bool">
|
||||
<label for="${inputId}">
|
||||
<span>${escapeHtml(opt.label)}</span>
|
||||
<span class="pp-filter-option-label">${escapeHtml(opt.label)}</span>
|
||||
<label class="settings-toggle" for="${inputId}">
|
||||
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
|
||||
|
||||
@@ -211,6 +211,18 @@ export async function deleteAudioSource(sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Refresh devices ───────────────────────────────────────────
|
||||
|
||||
export async function refreshAudioDevices() {
|
||||
const btn = document.getElementById('audio-source-refresh-devices');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await _loadAudioDevices();
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
let _cachedDevicesByEngine = {};
|
||||
|
||||
@@ -42,7 +42,7 @@ class AutomationEditorModal extends Modal {
|
||||
}
|
||||
|
||||
const automationModal = new AutomationEditorModal();
|
||||
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' });
|
||||
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations' });
|
||||
|
||||
/* ── Condition logic IconSelect ───────────────────────────────── */
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
|
||||
ICON_SUN_DIM, ICON_WARNING,
|
||||
ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION,
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
|
||||
loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
|
||||
} from './css-gradient-editor.js';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
|
||||
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -173,7 +175,7 @@ export function onCSSTypeChange() {
|
||||
_ensureAudioPaletteIconSelect();
|
||||
onAudioVizChange();
|
||||
}
|
||||
if (type === 'gradient') _ensureGradientPresetIconSelect();
|
||||
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
|
||||
if (type === 'notification') {
|
||||
_ensureNotificationEffectIconSelect();
|
||||
_ensureNotificationFilterModeIconSelect();
|
||||
@@ -415,19 +417,64 @@ function _ensureAudioVizIconSelect() {
|
||||
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureGradientPresetIconSelect() {
|
||||
const sel = document.getElementById('css-editor-gradient-preset');
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
function _buildGradientPresetItems() {
|
||||
const builtIn = [
|
||||
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
||||
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
})),
|
||||
];
|
||||
const custom = loadCustomGradientPresets().map(p => ({
|
||||
value: `__custom__${p.name}`,
|
||||
icon: gradientPresetStripHTML(p.stops),
|
||||
label: p.name,
|
||||
isCustom: true,
|
||||
}));
|
||||
return [...builtIn, ...custom];
|
||||
}
|
||||
|
||||
function _ensureGradientPresetIconSelect() {
|
||||
const sel = document.getElementById('css-editor-gradient-preset');
|
||||
if (!sel) return;
|
||||
const items = _buildGradientPresetItems();
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
/** Rebuild the preset picker after adding/removing custom presets. */
|
||||
export function refreshGradientPresetPicker() {
|
||||
if (_gradientPresetIconSelect) {
|
||||
_gradientPresetIconSelect.updateItems(_buildGradientPresetItems());
|
||||
_gradientPresetIconSelect.setValue('');
|
||||
}
|
||||
_renderCustomPresetList();
|
||||
}
|
||||
|
||||
/** Render the custom preset list below the save button. */
|
||||
function _renderCustomPresetList() {
|
||||
const container = document.getElementById('css-editor-custom-presets-list');
|
||||
if (!container) return;
|
||||
const presets = loadCustomGradientPresets();
|
||||
if (presets.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = presets.map(p => {
|
||||
const strip = gradientPresetStripHTML(p.stops, 60, 14);
|
||||
const safeName = escapeHtml(p.name);
|
||||
return `<div class="custom-preset-row">
|
||||
${strip}
|
||||
<span class="custom-preset-name">${safeName}</span>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-secondary"
|
||||
onclick="applyCustomGradientPreset(${JSON.stringify(p.name)})"
|
||||
title="${t('color_strip.gradient.preset.apply')}">✓</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||
onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
|
||||
title="${t('common.delete')}">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _ensureNotificationEffectIconSelect() {
|
||||
const sel = document.getElementById('css-editor-notification-effect');
|
||||
if (!sel) return;
|
||||
@@ -473,6 +520,40 @@ function _buildAnimationTypeItems(cssType) {
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Handles the gradient preset selector change — routes to built-in or custom preset. */
|
||||
export function onGradientPresetChange(value) {
|
||||
if (!value) return; // "— Custom —" selected
|
||||
if (value.startsWith('__custom__')) {
|
||||
applyCustomGradientPreset(value.slice('__custom__'.length));
|
||||
} else {
|
||||
applyGradientPreset(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */
|
||||
export function promptAndSaveGradientPreset() {
|
||||
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
|
||||
if (!name || !name.trim()) return;
|
||||
saveCurrentAsCustomPreset(name.trim());
|
||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||
refreshGradientPresetPicker();
|
||||
}
|
||||
|
||||
/** Apply a custom preset by name. */
|
||||
export function applyCustomGradientPreset(name) {
|
||||
const presets = loadCustomGradientPresets();
|
||||
const preset = presets.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
gradientInit(preset.stops);
|
||||
}
|
||||
|
||||
/** Delete a custom preset and refresh the picker. */
|
||||
export function deleteAndRefreshGradientPreset(name) {
|
||||
deleteCustomGradientPreset(name);
|
||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||
refreshGradientPresetPicker();
|
||||
}
|
||||
|
||||
function _ensureAnimationTypeIconSelect(cssType) {
|
||||
const sel = document.getElementById('css-editor-animation-type');
|
||||
if (!sel) return;
|
||||
@@ -650,8 +731,9 @@ function _compositeRenderList() {
|
||||
).join('');
|
||||
const canRemove = _compositeLayers.length > 1;
|
||||
return `
|
||||
<div class="composite-layer-item">
|
||||
<div class="composite-layer-item" data-layer-index="${i}">
|
||||
<div class="composite-layer-row">
|
||||
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||
<select class="composite-layer-blend" data-idx="${i}">
|
||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||
@@ -737,6 +819,8 @@ function _compositeRenderList() {
|
||||
noneLabel: t('common.none_no_cspt'),
|
||||
}));
|
||||
});
|
||||
|
||||
_initCompositeLayerDrag(list);
|
||||
}
|
||||
|
||||
export function compositeAddLayer() {
|
||||
@@ -780,6 +864,149 @@ function _compositeLayersSyncFromDom() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composite layer drag-to-reorder ── */
|
||||
|
||||
const _COMPOSITE_DRAG_THRESHOLD = 5;
|
||||
let _compositeLayerDragState = null;
|
||||
|
||||
function _initCompositeLayerDrag(list) {
|
||||
// Guard against stacking listeners across re-renders (the list DOM node persists).
|
||||
if (list._compositeDragBound) return;
|
||||
list._compositeDragBound = true;
|
||||
|
||||
list.addEventListener('pointerdown', (e) => {
|
||||
const handle = e.target.closest('.composite-layer-drag-handle');
|
||||
if (!handle) return;
|
||||
const item = handle.closest('.composite-layer-item');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const fromIndex = parseInt(item.dataset.layerIndex, 10);
|
||||
_compositeLayerDragState = {
|
||||
item,
|
||||
list,
|
||||
startY: e.clientY,
|
||||
started: false,
|
||||
clone: null,
|
||||
placeholder: null,
|
||||
offsetY: 0,
|
||||
fromIndex,
|
||||
scrollRaf: null,
|
||||
};
|
||||
|
||||
const onMove = (ev) => _onCompositeLayerDragMove(ev);
|
||||
const onUp = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
_onCompositeLayerDragEnd();
|
||||
};
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
}, { capture: false });
|
||||
}
|
||||
|
||||
function _onCompositeLayerDragMove(e) {
|
||||
const ds = _compositeLayerDragState;
|
||||
if (!ds) return;
|
||||
|
||||
if (!ds.started) {
|
||||
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
|
||||
_startCompositeLayerDrag(ds, e);
|
||||
}
|
||||
|
||||
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
|
||||
|
||||
const items = ds.list.querySelectorAll('.composite-layer-item');
|
||||
for (const it of items) {
|
||||
if (it.style.display === 'none') continue;
|
||||
const r = it.getBoundingClientRect();
|
||||
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
if (it === ds.lastTarget && before === ds.lastBefore) break;
|
||||
ds.lastTarget = it;
|
||||
ds.lastBefore = before;
|
||||
if (before) {
|
||||
ds.list.insertBefore(ds.placeholder, it);
|
||||
} else {
|
||||
ds.list.insertBefore(ds.placeholder, it.nextSibling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll near modal edges
|
||||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||
const modal = ds.list.closest('.modal-body');
|
||||
if (modal) {
|
||||
const EDGE = 60, SPEED = 12;
|
||||
const mr = modal.getBoundingClientRect();
|
||||
let speed = 0;
|
||||
if (e.clientY < mr.top + EDGE) speed = -SPEED;
|
||||
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
|
||||
if (speed !== 0) {
|
||||
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
|
||||
ds.scrollRaf = requestAnimationFrame(scroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _startCompositeLayerDrag(ds, e) {
|
||||
ds.started = true;
|
||||
const rect = ds.item.getBoundingClientRect();
|
||||
|
||||
const clone = ds.item.cloneNode(true);
|
||||
clone.className = ds.item.className + ' composite-layer-drag-clone';
|
||||
clone.style.width = rect.width + 'px';
|
||||
clone.style.left = rect.left + 'px';
|
||||
clone.style.top = rect.top + 'px';
|
||||
document.body.appendChild(clone);
|
||||
ds.clone = clone;
|
||||
ds.offsetY = e.clientY - rect.top;
|
||||
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'composite-layer-drag-placeholder';
|
||||
placeholder.style.height = rect.height + 'px';
|
||||
ds.item.parentNode.insertBefore(placeholder, ds.item);
|
||||
ds.placeholder = placeholder;
|
||||
|
||||
ds.item.style.display = 'none';
|
||||
document.body.classList.add('composite-layer-dragging');
|
||||
}
|
||||
|
||||
function _onCompositeLayerDragEnd() {
|
||||
const ds = _compositeLayerDragState;
|
||||
_compositeLayerDragState = null;
|
||||
if (!ds || !ds.started) return;
|
||||
|
||||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||
|
||||
// Determine new index from placeholder position
|
||||
let toIndex = 0;
|
||||
for (const child of ds.list.children) {
|
||||
if (child === ds.placeholder) break;
|
||||
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
|
||||
toIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup DOM
|
||||
ds.item.style.display = '';
|
||||
ds.placeholder.remove();
|
||||
ds.clone.remove();
|
||||
document.body.classList.remove('composite-layer-dragging');
|
||||
|
||||
// Sync current DOM values before reordering
|
||||
_compositeLayersSyncFromDom();
|
||||
|
||||
// Reorder array and re-render
|
||||
if (toIndex !== ds.fromIndex) {
|
||||
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
|
||||
_compositeLayers.splice(toIndex, 0, moved);
|
||||
_compositeRenderList();
|
||||
}
|
||||
}
|
||||
|
||||
function _compositeGetLayers() {
|
||||
_compositeLayersSyncFromDom();
|
||||
return _compositeLayers.map(l => {
|
||||
@@ -1074,6 +1301,79 @@ export async function testNotification(sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OS Notification History Modal ─────────────────────────────────────────
|
||||
|
||||
export function showNotificationHistory() {
|
||||
const modal = document.getElementById('notification-history-modal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'flex';
|
||||
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
|
||||
_loadNotificationHistory();
|
||||
}
|
||||
|
||||
export function closeNotificationHistory() {
|
||||
const modal = document.getElementById('notification-history-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export async function refreshNotificationHistory() {
|
||||
await _loadNotificationHistory();
|
||||
}
|
||||
|
||||
async function _loadNotificationHistory() {
|
||||
const list = document.getElementById('notification-history-list');
|
||||
const status = document.getElementById('notification-history-status');
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.available) {
|
||||
list.innerHTML = '';
|
||||
if (status) {
|
||||
status.textContent = t('color_strip.notification.history.unavailable');
|
||||
status.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) status.style.display = 'none';
|
||||
|
||||
const history = data.history || [];
|
||||
if (history.length === 0) {
|
||||
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = history.map(entry => {
|
||||
const appName = entry.app || t('color_strip.notification.history.unknown_app');
|
||||
const timeStr = new Date(entry.time * 1000).toLocaleString();
|
||||
const fired = entry.fired ?? 0;
|
||||
const filtered = entry.filtered ?? 0;
|
||||
const firedBadge = fired > 0
|
||||
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
|
||||
: '';
|
||||
const filteredBadge = filtered > 0
|
||||
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
|
||||
: '';
|
||||
return `<div class="notif-history-row">
|
||||
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
|
||||
<div class="notif-history-time">${timeStr}</div>
|
||||
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load notification history:', err);
|
||||
if (status) {
|
||||
status.textContent = t('color_strip.notification.history.error');
|
||||
status.style.display = '';
|
||||
}
|
||||
list.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _notificationAppColorsSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-colors-list');
|
||||
if (!list) return;
|
||||
@@ -1353,6 +1653,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const testNotifyBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||
: '';
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = !isApiInput
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||
: '';
|
||||
@@ -1375,7 +1678,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
${calibrationBtn}${overlayBtn}${testNotifyBtn}${testPreviewBtn}`,
|
||||
${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,41 @@ function _gradientStartDrag(e, idx) {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Custom presets (localStorage) ───────────────────────────── */
|
||||
|
||||
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
|
||||
|
||||
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
|
||||
export function loadCustomGradientPresets() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the current gradient stops as a named custom preset. */
|
||||
export function saveCurrentAsCustomPreset(name) {
|
||||
if (!name) return;
|
||||
const stops = _gradientStops.map(s => ({
|
||||
position: s.position,
|
||||
color: [...s.color],
|
||||
...(s.colorRight ? { color_right: [...s.colorRight] } : {}),
|
||||
}));
|
||||
const presets = loadCustomGradientPresets();
|
||||
// Replace if same name exists
|
||||
const idx = presets.findIndex(p => p.name === name);
|
||||
if (idx >= 0) presets[idx] = { name, stops };
|
||||
else presets.push({ name, stops });
|
||||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
/** Delete a custom preset by name. */
|
||||
export function deleteCustomGradientPreset(name) {
|
||||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.js';
|
||||
import {
|
||||
@@ -733,6 +733,8 @@ export async function dashboardStopTarget(targetId) {
|
||||
}
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
|
||||
@@ -582,7 +582,7 @@ export function onSerialPortFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddDevice(presetType = null) {
|
||||
export function showAddDevice(presetType = null, cloneData = null) {
|
||||
// When no type specified: show type picker first
|
||||
if (!presetType) {
|
||||
showTypePicker({
|
||||
@@ -623,6 +623,47 @@ export function showAddDevice(presetType = null) {
|
||||
|
||||
addDeviceModal.open();
|
||||
onDeviceTypeChanged();
|
||||
|
||||
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
|
||||
if (cloneData) {
|
||||
document.getElementById('device-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
// Clear URL — devices must have unique addresses, user must enter a new one
|
||||
const urlInput = document.getElementById('device-url');
|
||||
if (urlInput) urlInput.value = '';
|
||||
// Prefill LED count
|
||||
const ledCountInput = document.getElementById('device-led-count');
|
||||
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
|
||||
// Prefill baud rate for serial devices
|
||||
if (isSerialDevice(presetType)) {
|
||||
const baudSelect = document.getElementById('device-baud-rate');
|
||||
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
|
||||
}
|
||||
// Prefill mock device fields
|
||||
if (isMockDevice(presetType)) {
|
||||
const ledTypeEl = document.getElementById('device-led-type');
|
||||
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
|
||||
const sendLatencyEl = document.getElementById('device-send-latency');
|
||||
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
|
||||
}
|
||||
// Prefill DMX fields
|
||||
if (isDmxDevice(presetType)) {
|
||||
const dmxProto = document.getElementById('device-dmx-protocol');
|
||||
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
|
||||
const dmxUniverse = document.getElementById('device-dmx-start-universe');
|
||||
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
|
||||
const dmxChannel = document.getElementById('device-dmx-start-channel');
|
||||
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
|
||||
}
|
||||
// Prefill CSPT template selector (after fetch completes)
|
||||
if (cloneData.default_css_processing_template_id) {
|
||||
csptCache.fetch().then(() => {
|
||||
_ensureCsptEntitySelect();
|
||||
const csptEl = document.getElementById('device-css-processing-template');
|
||||
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
desktopFocus(document.getElementById('device-name'));
|
||||
addDeviceModal.snapshot();
|
||||
@@ -984,3 +1025,18 @@ function _showGameSenseFields(show) {
|
||||
const el = document.getElementById('device-gamesense-device-type-group');
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Clone device ──────────────────────────────────────────────── */
|
||||
|
||||
export async function cloneDevice(deviceId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load device');
|
||||
const device = await resp.json();
|
||||
showAddDevice(device.device_type || 'wled', device);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone device:', error);
|
||||
showToast(t('device.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
@@ -95,6 +95,22 @@ class DeviceSettingsModal extends Modal {
|
||||
|
||||
const settingsModal = new DeviceSettingsModal();
|
||||
|
||||
function _formatRelativeTime(isoString) {
|
||||
if (!isoString) return null;
|
||||
const then = new Date(isoString);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
if (diffMs < 0) return null;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 5) return t('device.last_seen.just_now');
|
||||
if (diffSec < 60) return t('device.last_seen.seconds').replace('%d', diffSec);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return t('device.last_seen.minutes').replace('%d', diffMin);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return t('device.last_seen.hours').replace('%d', diffHr);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return t('device.last_seen.days').replace('%d', diffDay);
|
||||
}
|
||||
|
||||
export function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
|
||||
@@ -124,6 +140,7 @@ export function createDeviceCard(device) {
|
||||
}
|
||||
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
const lastSeenLabel = devLastChecked ? _formatRelativeTime(devLastChecked) : null;
|
||||
|
||||
// Parse zone names from OpenRGB URL for badge display
|
||||
const openrgbZones = isOpenrgbDevice(device.device_type)
|
||||
@@ -152,6 +169,7 @@ export function createDeviceCard(device) {
|
||||
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
</div>
|
||||
${lastSeenLabel ? `<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" title="${devLastChecked}">⏱ ${t('device.last_seen.label')}: ${lastSeenLabel}</span></div>` : ''}
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
@@ -166,6 +184,9 @@ export function createDeviceCard(device) {
|
||||
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
|
||||
${ICON_REFRESH}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
|
||||
${ICON_CLONE}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
${ICON_SETTINGS}
|
||||
</button>`,
|
||||
@@ -173,6 +194,8 @@ export function createDeviceCard(device) {
|
||||
}
|
||||
|
||||
export async function turnOffDevice(deviceId) {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CardSection } from '../core/card-sections.js';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache, outputTargetsCache } from '../core/state.js';
|
||||
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
@@ -43,13 +43,18 @@ export const csScenes = new CardSection('scenes', {
|
||||
gridClass: 'devices-grid',
|
||||
addCardOnclick: "openScenePresetCapture()",
|
||||
keyAttr: 'data-scene-id',
|
||||
emptyKey: 'section.empty.scenes',
|
||||
});
|
||||
|
||||
export function createSceneCard(preset) {
|
||||
const targetCount = (preset.targets || []).length;
|
||||
|
||||
const automations = automationsCacheObj.data || [];
|
||||
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
|
||||
|
||||
const meta = [
|
||||
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
|
||||
usedByCount > 0 ? `🔗 ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
|
||||
|
||||
@@ -9,17 +9,139 @@ import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let _logWs = null;
|
||||
|
||||
/** Level ordering for filter comparisons */
|
||||
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
|
||||
|
||||
function _detectLevel(line) {
|
||||
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
|
||||
if (line.includes(lvl)) return lvl;
|
||||
}
|
||||
return 'DEBUG';
|
||||
}
|
||||
|
||||
function _levelClass(level) {
|
||||
if (level === 'ERROR' || level === 'CRITICAL') return 'log-line-error';
|
||||
if (level === 'WARNING') return 'log-line-warning';
|
||||
if (level === 'DEBUG') return 'log-line-debug';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _filterLevel() {
|
||||
const sel = document.getElementById('log-viewer-filter');
|
||||
return sel ? sel.value : 'all';
|
||||
}
|
||||
|
||||
function _linePassesFilter(line) {
|
||||
const filter = _filterLevel();
|
||||
if (filter === 'all') return true;
|
||||
const lineLvl = _detectLevel(line);
|
||||
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
}
|
||||
|
||||
function _appendLine(line) {
|
||||
// Skip keepalive empty pings
|
||||
if (!line) return;
|
||||
if (!_linePassesFilter(line)) return;
|
||||
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
|
||||
const level = _detectLevel(line);
|
||||
const cls = _levelClass(level);
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (cls) span.className = cls;
|
||||
span.textContent = line + '\n';
|
||||
output.appendChild(span);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
export function connectLogViewer() {
|
||||
const btn = document.getElementById('log-viewer-connect-btn');
|
||||
|
||||
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
|
||||
// Disconnect
|
||||
_logWs.close();
|
||||
_logWs = null;
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/v1/system/logs/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
_logWs = new WebSocket(url);
|
||||
|
||||
_logWs.onopen = () => {
|
||||
if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; }
|
||||
};
|
||||
|
||||
_logWs.onmessage = (evt) => {
|
||||
_appendLine(evt.data);
|
||||
};
|
||||
|
||||
_logWs.onerror = () => {
|
||||
showToast(t('settings.logs.error'), 'error');
|
||||
};
|
||||
|
||||
_logWs.onclose = () => {
|
||||
_logWs = null;
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectLogViewer() {
|
||||
if (_logWs) {
|
||||
_logWs.close();
|
||||
_logWs = null;
|
||||
}
|
||||
const btn = document.getElementById('log-viewer-connect-btn');
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
}
|
||||
|
||||
export function clearLogViewer() {
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (output) output.innerHTML = '';
|
||||
}
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
export function applyLogFilter() {
|
||||
// We don't buffer all raw lines in JS — just clear and note the filter
|
||||
// will apply to future lines. Existing lines that were already rendered
|
||||
// are re-evaluated by toggling their visibility.
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
for (const span of output.children) {
|
||||
const line = span.textContent;
|
||||
const lineLvl = _detectLevel(line);
|
||||
const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
span.style.display = passes ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Simple modal (no form / no dirty check needed)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
settingsModal.open();
|
||||
loadApiKeysList();
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
loadMqttSettings();
|
||||
loadLogLevel();
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
disconnectLogViewer();
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
@@ -90,9 +212,30 @@ export async function handleRestoreFileSelected(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server restart ────────────────────────────────────────
|
||||
|
||||
export async function restartServer() {
|
||||
const confirmed = await showConfirm(t('settings.restart_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/restart', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
settingsModal.forceClose();
|
||||
showRestartOverlay(t('settings.restarting'));
|
||||
} catch (err) {
|
||||
console.error('Server restart failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Restart overlay ───────────────────────────────────────
|
||||
|
||||
function showRestartOverlay() {
|
||||
function showRestartOverlay(message) {
|
||||
const msg = message || t('settings.restore.restarting');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'restart-overlay';
|
||||
overlay.style.cssText =
|
||||
@@ -101,7 +244,7 @@ function showRestartOverlay() {
|
||||
overlay.innerHTML =
|
||||
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
|
||||
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
|
||||
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`;
|
||||
`<div id="restart-msg">${msg}</div>`;
|
||||
|
||||
// Add spinner animation if not present
|
||||
if (!document.getElementById('restart-spinner-style')) {
|
||||
@@ -201,12 +344,20 @@ export async function loadBackupList() {
|
||||
}
|
||||
|
||||
container.innerHTML = data.backups.map(b => {
|
||||
const sizeKB = (b.size_bytes / 1024).toFixed(1);
|
||||
const sizeBytes = b.size_bytes || 0;
|
||||
const sizeStr = sizeBytes >= 1024 * 1024
|
||||
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
: (sizeBytes / 1024).toFixed(1) + ' KB';
|
||||
const date = new Date(b.created_at).toLocaleString();
|
||||
const isAuto = b.filename.startsWith('ledgrab-autobackup-');
|
||||
const typeBadge = isAuto
|
||||
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>`
|
||||
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`;
|
||||
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
|
||||
${typeBadge}
|
||||
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
|
||||
<span>${date}</span>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeStr}</span>
|
||||
</div>
|
||||
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button>
|
||||
@@ -299,3 +450,192 @@ export async function deleteSavedBackup(filename) {
|
||||
showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API Keys (read-only display) ─────────────────────────────
|
||||
|
||||
export async function loadApiKeysList() {
|
||||
const container = document.getElementById('settings-api-keys-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/api-keys');
|
||||
if (!resp.ok) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.count === 0) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.keys.map(k =>
|
||||
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);">
|
||||
<span style="font-weight:600;min-width:80px;">${k.label}</span>
|
||||
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code>
|
||||
</div>`
|
||||
).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Partial Export / Import ───────────────────────────────────
|
||||
|
||||
export async function downloadPartialExport() {
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/export/${encodeURIComponent(storeKey)}`, { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const disposition = resp.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename="(.+?)"/);
|
||||
const filename = match ? match[1] : `ledgrab-${storeKey}.json`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
|
||||
showToast(t('settings.partial.export_success'), 'success');
|
||||
} catch (err) {
|
||||
console.error('Partial export failed:', err);
|
||||
showToast(t('settings.partial.export_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePartialImportFileSelected(input) {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
const merge = document.getElementById('settings-partial-merge').checked;
|
||||
const confirmMsg = merge
|
||||
? t('settings.partial.import_confirm_merge').replace('{store}', storeKey)
|
||||
: t('settings.partial.import_confirm_replace').replace('{store}', storeKey);
|
||||
|
||||
const confirmed = await showConfirm(confirmMsg);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const url = `${API_BASE}/system/import/${encodeURIComponent(storeKey)}?merge=${merge}`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
showToast(data.message || t('settings.partial.import_success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Partial import failed:', err);
|
||||
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Level ────────────────────────────────────────────────
|
||||
|
||||
export async function loadLogLevel() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/log-level');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const select = document.getElementById('settings-log-level');
|
||||
if (select) select.value = data.level;
|
||||
} catch (err) {
|
||||
console.error('Failed to load log level:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogLevel() {
|
||||
const select = document.getElementById('settings-log-level');
|
||||
if (!select) return;
|
||||
const level = select.value;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/log-level', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ level }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('settings.log_level.saved'), 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to set log level:', err);
|
||||
showToast(t('settings.log_level.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MQTT settings ────────────────────────────────────────────
|
||||
|
||||
export async function loadMqttSettings() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('mqtt-enabled').checked = data.enabled;
|
||||
document.getElementById('mqtt-host').value = data.broker_host;
|
||||
document.getElementById('mqtt-port').value = data.broker_port;
|
||||
document.getElementById('mqtt-username').value = data.username;
|
||||
document.getElementById('mqtt-password').value = '';
|
||||
document.getElementById('mqtt-client-id').value = data.client_id;
|
||||
document.getElementById('mqtt-base-topic').value = data.base_topic;
|
||||
|
||||
const hint = document.getElementById('mqtt-password-hint');
|
||||
if (hint) hint.style.display = data.password_set ? '' : 'none';
|
||||
} catch (err) {
|
||||
console.error('Failed to load MQTT settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMqttSettings() {
|
||||
const enabled = document.getElementById('mqtt-enabled').checked;
|
||||
const broker_host = document.getElementById('mqtt-host').value.trim();
|
||||
const broker_port = parseInt(document.getElementById('mqtt-port').value, 10);
|
||||
const username = document.getElementById('mqtt-username').value;
|
||||
const password = document.getElementById('mqtt-password').value;
|
||||
const client_id = document.getElementById('mqtt-client-id').value.trim();
|
||||
const base_topic = document.getElementById('mqtt-base-topic').value.trim();
|
||||
|
||||
if (!broker_host) {
|
||||
showToast(t('settings.mqtt.error_host_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, broker_host, broker_port, username, password, client_id, base_topic }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('settings.mqtt.saved'), 'success');
|
||||
loadMqttSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to save MQTT settings:', err);
|
||||
showToast(t('settings.mqtt.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.js';
|
||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { TreeNav } from '../core/tree-nav.js';
|
||||
@@ -70,19 +70,19 @@ let _audioTemplateTagsInput = null;
|
||||
let _csptTagsInput = null;
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
|
||||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
|
||||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
|
||||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id' });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' });
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' });
|
||||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' });
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates' });
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips' });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -322,6 +322,7 @@ export async function showTestTemplateModal(templateId) {
|
||||
restoreCaptureDuration();
|
||||
|
||||
testTemplateModal.open();
|
||||
setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal());
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('templates.error.load'), 'error');
|
||||
@@ -2162,6 +2163,7 @@ export async function showTestStreamModal(streamId) {
|
||||
restoreStreamTestDuration();
|
||||
|
||||
testStreamModal.open();
|
||||
setupBackdropClose(testStreamModal.el, () => closeTestStreamModal());
|
||||
}
|
||||
|
||||
export function closeTestStreamModal() {
|
||||
@@ -2229,6 +2231,7 @@ export async function showTestPPTemplateModal(templateId) {
|
||||
});
|
||||
|
||||
testPPTemplateModal.open();
|
||||
setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal());
|
||||
}
|
||||
|
||||
export function closeTestPPTemplateModal() {
|
||||
|
||||
@@ -190,6 +190,15 @@ export async function resetSyncClock(clockId) {
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
function _formatElapsed(seconds) {
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function createSyncClockCard(clock) {
|
||||
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
|
||||
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
|
||||
@@ -197,6 +206,7 @@ export function createSyncClockCard(clock) {
|
||||
? `pauseSyncClock('${clock.id}')`
|
||||
: `resumeSyncClock('${clock.id}')`;
|
||||
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
|
||||
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
@@ -211,6 +221,7 @@ export function createSyncClockCard(clock) {
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
|
||||
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
|
||||
${elapsedLabel ? `<span class="stream-card-prop" title="${t('sync_clock.elapsed')}">⏱ ${elapsedLabel}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(clock.tags)}
|
||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||
|
||||
@@ -40,10 +40,10 @@ import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
|
||||
// ── Card section instances ──
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' });
|
||||
|
||||
// Re-render targets tab when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
@@ -189,8 +189,12 @@ function _updateSpecificSettingsVisibility() {
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
|
||||
// Hide entire Specific Settings section for non-WLED devices (protocol + keepalive are WLED-only)
|
||||
document.getElementById('target-editor-device-settings').style.display = isWled ? '' : 'none';
|
||||
// Hide WLED-only controls (protocol + keepalive) for non-WLED devices
|
||||
const protocolGroup = document.getElementById('target-editor-protocol-group');
|
||||
if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none';
|
||||
// keepalive is controlled further by _updateKeepaliveVisibility
|
||||
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
|
||||
if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
function _updateBrightnessThresholdVisibility() {
|
||||
@@ -1069,10 +1073,14 @@ export async function stopTargetProcessing(targetId) {
|
||||
}
|
||||
|
||||
export async function stopAllLedTargets() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
await _stopAllByType('led');
|
||||
}
|
||||
|
||||
export async function stopAllKCTargets() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
await _stopAllByType('key_colors');
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export function startCalibrationTutorial() {
|
||||
if (!container) return;
|
||||
startTutorial({
|
||||
steps: calibrationTutorialSteps,
|
||||
overlayId: 'tutorial-overlay',
|
||||
overlayId: 'calibration-tutorial-overlay',
|
||||
mode: 'absolute',
|
||||
container: container,
|
||||
resolveTarget: (step) => {
|
||||
|
||||
@@ -134,6 +134,88 @@ function _ensureWaveformIconSelect() {
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
|
||||
}
|
||||
|
||||
/* ── Waveform canvas preview ──────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
|
||||
* Shows one full cycle of the selected waveform shape.
|
||||
*/
|
||||
function _drawWaveformPreview(waveformType) {
|
||||
const canvas = document.getElementById('value-source-waveform-preview');
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.offsetWidth || 200;
|
||||
const cssH = 60;
|
||||
canvas.width = cssW * dpr;
|
||||
canvas.height = cssH * dpr;
|
||||
canvas.style.height = cssH + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const W = cssW;
|
||||
const H = cssH;
|
||||
const padX = 8;
|
||||
const padY = 8;
|
||||
const drawW = W - padX * 2;
|
||||
const drawH = H - padY * 2;
|
||||
const midY = padY + drawH / 2;
|
||||
|
||||
// Draw zero line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, midY);
|
||||
ctx.lineTo(padX + drawW, midY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw waveform
|
||||
const N = 120;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N; // 0..1 over one cycle
|
||||
let v; // -1..1
|
||||
switch (waveformType) {
|
||||
case 'triangle':
|
||||
v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
|
||||
break;
|
||||
case 'square':
|
||||
v = t < 0.5 ? 1 : -1;
|
||||
break;
|
||||
case 'sawtooth':
|
||||
v = 2 * t - 1;
|
||||
break;
|
||||
case 'sine':
|
||||
default:
|
||||
v = Math.sin(2 * Math.PI * t);
|
||||
break;
|
||||
}
|
||||
const x = padX + t * drawW;
|
||||
const y = midY - v * (drawH / 2);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Glow effect: draw thick translucent line first
|
||||
ctx.strokeStyle = 'rgba(99,179,237,0.25)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.stroke();
|
||||
|
||||
// Crisp line on top
|
||||
ctx.strokeStyle = '#63b3ed';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function updateWaveformPreview() {
|
||||
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
|
||||
_drawWaveformPreview(wf);
|
||||
}
|
||||
|
||||
/* ── Audio mode icon-grid selector ────────────────────────────── */
|
||||
|
||||
const _AUDIO_MODE_SVG = {
|
||||
@@ -208,6 +290,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
} else if (editData.source_type === 'animated') {
|
||||
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
|
||||
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
|
||||
_drawWaveformPreview(editData.waveform || 'sine');
|
||||
_setSlider('value-source-speed', editData.speed ?? 10);
|
||||
_setSlider('value-source-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-max-value', editData.max_value ?? 1);
|
||||
@@ -249,6 +332,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
_setSlider('value-source-min-value', 0);
|
||||
_setSlider('value-source-max-value', 1);
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_drawWaveformPreview('sine');
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
|
||||
@@ -274,7 +358,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
}
|
||||
|
||||
// Wire up auto-name triggers
|
||||
document.getElementById('value-source-waveform').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
|
||||
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
|
||||
|
||||
@@ -296,7 +380,7 @@ export function onValueSourceTypeChange() {
|
||||
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
|
||||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
if (type === 'animated') _ensureWaveformIconSelect();
|
||||
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
if (type === 'audio') _ensureAudioModeIconSelect();
|
||||
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.toggle_password": "Toggle password visibility",
|
||||
"api_key.login": "Login",
|
||||
"displays.title": "Available Displays",
|
||||
"displays.layout": "Displays",
|
||||
"displays.information": "Display Information",
|
||||
@@ -291,6 +292,12 @@
|
||||
"device.health.offline": "Offline",
|
||||
"device.health.streaming_unreachable": "Unreachable during streaming",
|
||||
"device.health.checking": "Checking...",
|
||||
"device.last_seen.label": "Last seen",
|
||||
"device.last_seen.just_now": "just now",
|
||||
"device.last_seen.seconds": "%ds ago",
|
||||
"device.last_seen.minutes": "%dm ago",
|
||||
"device.last_seen.hours": "%dh ago",
|
||||
"device.last_seen.days": "%dd ago",
|
||||
"device.tutorial.start": "Start tutorial",
|
||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||
"device.tip.brightness": "Slide to adjust device brightness",
|
||||
@@ -405,6 +412,8 @@
|
||||
"confirm.title": "Confirm Action",
|
||||
"confirm.yes": "Yes",
|
||||
"confirm.no": "No",
|
||||
"confirm.stop_all": "Stop all running targets?",
|
||||
"confirm.turn_off_device": "Turn off this device?",
|
||||
"common.loading": "Loading...",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
@@ -584,6 +593,7 @@
|
||||
"targets.section.color_strips": "Color Strip Sources",
|
||||
"targets.section.targets": "Targets",
|
||||
"targets.section.specific_settings": "Specific Settings",
|
||||
"targets.section.advanced": "Advanced",
|
||||
"targets.add": "Add Target",
|
||||
"targets.edit": "Edit Target",
|
||||
"targets.loading": "Loading targets...",
|
||||
@@ -953,6 +963,11 @@
|
||||
"color_strip.gradient.preset.cool": "Cool",
|
||||
"color_strip.gradient.preset.neon": "Neon",
|
||||
"color_strip.gradient.preset.pastel": "Pastel",
|
||||
"color_strip.gradient.preset.save_button": "Save as preset…",
|
||||
"color_strip.gradient.preset.save_prompt": "Enter a name for this preset:",
|
||||
"color_strip.gradient.preset.saved": "Preset saved",
|
||||
"color_strip.gradient.preset.deleted": "Preset deleted",
|
||||
"color_strip.gradient.preset.apply": "Apply",
|
||||
"color_strip.animation": "Animation",
|
||||
"color_strip.animation.type": "Effect:",
|
||||
"color_strip.animation.type.hint": "Animation effect to apply.",
|
||||
@@ -1043,6 +1058,15 @@
|
||||
"color_strip.notification.test.ok": "Notification sent",
|
||||
"color_strip.notification.test.no_streams": "No running streams for this source",
|
||||
"color_strip.notification.test.error": "Failed to send notification",
|
||||
"color_strip.notification.history.title": "Notification History",
|
||||
"color_strip.notification.history.hint": "Recent OS notifications captured by the listener (newest first). Up to 50 entries.",
|
||||
"color_strip.notification.history.empty": "No notifications captured yet",
|
||||
"color_strip.notification.history.unavailable": "OS notification listener is not available on this platform",
|
||||
"color_strip.notification.history.error": "Failed to load notification history",
|
||||
"color_strip.notification.history.refresh": "Refresh",
|
||||
"color_strip.notification.history.unknown_app": "Unknown app",
|
||||
"color_strip.notification.history.fired": "Streams triggered",
|
||||
"color_strip.notification.history.filtered": "Streams filtered",
|
||||
"color_strip.test.title": "Test Preview",
|
||||
"color_strip.test.connecting": "Connecting...",
|
||||
"color_strip.test.error": "Failed to connect to preview stream",
|
||||
@@ -1188,6 +1212,7 @@
|
||||
"audio_source.type.mono": "Mono",
|
||||
"audio_source.device": "Audio Device:",
|
||||
"audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
|
||||
"audio_source.refresh_devices": "Refresh devices",
|
||||
"audio_source.parent": "Parent Source:",
|
||||
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
||||
"audio_source.channel": "Channel:",
|
||||
@@ -1375,6 +1400,13 @@
|
||||
"search.group.value": "Value Sources",
|
||||
"search.group.scenes": "Scene Presets",
|
||||
"search.group.cspt": "Strip Processing Templates",
|
||||
"search.group.sync_clocks": "Sync Clocks",
|
||||
"search.group.actions": "Actions",
|
||||
"search.action.start": "Start",
|
||||
"search.action.stop": "Stop",
|
||||
"search.action.activate": "Activate",
|
||||
"search.action.enable": "Enable",
|
||||
"search.action.disable": "Disable",
|
||||
"settings.backup.label": "Backup Configuration",
|
||||
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
|
||||
"settings.backup.button": "Download Backup",
|
||||
@@ -1388,7 +1420,15 @@
|
||||
"settings.restore.error": "Restore failed",
|
||||
"settings.restore.restarting": "Server is restarting...",
|
||||
"settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.",
|
||||
"settings.restart_server": "Restart Server",
|
||||
"settings.restart_confirm": "Restart the server? Active targets will be stopped.",
|
||||
"settings.restarting": "Restarting server...",
|
||||
"settings.button.close": "Close",
|
||||
"settings.log_level.label": "Log Level",
|
||||
"settings.log_level.hint": "Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.",
|
||||
"settings.log_level.save": "Apply",
|
||||
"settings.log_level.saved": "Log level changed",
|
||||
"settings.log_level.save_error": "Failed to change log level",
|
||||
"settings.auto_backup.label": "Auto-Backup",
|
||||
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
|
||||
"settings.auto_backup.enable": "Enable auto-backup",
|
||||
@@ -1407,6 +1447,32 @@
|
||||
"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.type.auto": "auto",
|
||||
"settings.saved_backups.type.manual": "manual",
|
||||
"settings.mqtt.label": "MQTT",
|
||||
"settings.mqtt.hint": "Configure MQTT broker connection for automation conditions and triggers.",
|
||||
"settings.mqtt.enabled": "Enable MQTT",
|
||||
"settings.mqtt.host_label": "Broker Host",
|
||||
"settings.mqtt.port_label": "Port",
|
||||
"settings.mqtt.username_label": "Username",
|
||||
"settings.mqtt.password_label": "Password",
|
||||
"settings.mqtt.password_set_hint": "Password is set — leave blank to keep",
|
||||
"settings.mqtt.client_id_label": "Client ID",
|
||||
"settings.mqtt.base_topic_label": "Base Topic",
|
||||
"settings.mqtt.save": "Save MQTT Settings",
|
||||
"settings.mqtt.saved": "MQTT settings saved",
|
||||
"settings.mqtt.save_error": "Failed to save MQTT settings",
|
||||
"settings.mqtt.error_host_required": "Broker host is required",
|
||||
"settings.logs.label": "Server Logs",
|
||||
"settings.logs.hint": "Stream live server log output. Use the filter to show only relevant log levels.",
|
||||
"settings.logs.connect": "Connect",
|
||||
"settings.logs.disconnect": "Disconnect",
|
||||
"settings.logs.clear": "Clear",
|
||||
"settings.logs.error": "Log viewer connection failed",
|
||||
"settings.logs.filter.all": "All levels",
|
||||
"settings.logs.filter.info": "Info+",
|
||||
"settings.logs.filter.warning": "Warning+",
|
||||
"settings.logs.filter.error": "Error only",
|
||||
"device.error.power_off_failed": "Failed to turn off device",
|
||||
"device.removed": "Device removed",
|
||||
"device.error.remove_failed": "Failed to remove device",
|
||||
@@ -1415,6 +1481,7 @@
|
||||
"device.error.required": "Please fill in all fields correctly",
|
||||
"device.error.update": "Failed to update device",
|
||||
"device.error.save": "Failed to save settings",
|
||||
"device.error.clone_failed": "Failed to clone device",
|
||||
"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",
|
||||
@@ -1506,6 +1573,7 @@
|
||||
"sync_clock.resumed": "Clock resumed",
|
||||
"sync_clock.reset_done": "Clock reset to zero",
|
||||
"sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.",
|
||||
"sync_clock.elapsed": "Elapsed time",
|
||||
"color_strip.clock": "Sync Clock:",
|
||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||
"graph.title": "Graph",
|
||||
@@ -1570,5 +1638,50 @@
|
||||
"graph.help.right_click_desc": "Detach connection",
|
||||
"automation.enabled": "Automation enabled",
|
||||
"automation.disabled": "Automation disabled",
|
||||
"scene_preset.activated": "Preset activated"
|
||||
"scene_preset.activated": "Preset activated",
|
||||
"scene_preset.used_by": "Used by %d automation(s)",
|
||||
"settings.api_keys.label": "API Keys",
|
||||
"settings.api_keys.hint": "API keys are defined in the server config file (config.yaml). Edit the file and restart the server to apply changes.",
|
||||
"settings.api_keys.empty": "No API keys configured",
|
||||
"settings.api_keys.load_error": "Failed to load API keys",
|
||||
"settings.partial.label": "Partial Export / Import",
|
||||
"settings.partial.hint": "Export or import a single entity type. Import replaces or merges existing data and restarts the server.",
|
||||
"settings.partial.store.devices": "Devices",
|
||||
"settings.partial.store.output_targets": "LED Targets",
|
||||
"settings.partial.store.color_strip_sources": "Color Strips",
|
||||
"settings.partial.store.picture_sources": "Picture Sources",
|
||||
"settings.partial.store.audio_sources": "Audio Sources",
|
||||
"settings.partial.store.audio_templates": "Audio Templates",
|
||||
"settings.partial.store.capture_templates": "Capture Templates",
|
||||
"settings.partial.store.postprocessing_templates": "Post-processing Templates",
|
||||
"settings.partial.store.color_strip_processing_templates": "CSS Processing Templates",
|
||||
"settings.partial.store.pattern_templates": "Pattern Templates",
|
||||
"settings.partial.store.value_sources": "Value Sources",
|
||||
"settings.partial.store.sync_clocks": "Sync Clocks",
|
||||
"settings.partial.store.automations": "Automations",
|
||||
"settings.partial.store.scene_presets": "Scene Presets",
|
||||
"settings.partial.export_button": "Export",
|
||||
"settings.partial.import_button": "Import from File",
|
||||
"settings.partial.merge_label": "Merge (add/overwrite, keep existing)",
|
||||
"settings.partial.export_success": "Exported successfully",
|
||||
"settings.partial.export_error": "Export failed",
|
||||
"settings.partial.import_success": "Imported successfully",
|
||||
"settings.partial.import_error": "Import failed",
|
||||
"settings.partial.import_confirm_replace": "This will REPLACE all {store} data and restart the server. Continue?",
|
||||
"settings.partial.import_confirm_merge": "This will MERGE into existing {store} data and restart the server. Continue?",
|
||||
"section.empty.devices": "No devices yet. Click + to add one.",
|
||||
"section.empty.targets": "No LED targets yet. Click + to add one.",
|
||||
"section.empty.kc_targets": "No key color targets yet. Click + to add one.",
|
||||
"section.empty.pattern_templates": "No pattern templates yet. Click + to add one.",
|
||||
"section.empty.picture_sources": "No sources yet. Click + to add one.",
|
||||
"section.empty.capture_templates": "No capture templates yet. Click + to add one.",
|
||||
"section.empty.pp_templates": "No post-processing templates yet. Click + to add one.",
|
||||
"section.empty.audio_sources": "No audio sources yet. Click + to add one.",
|
||||
"section.empty.audio_templates": "No audio templates yet. Click + to add one.",
|
||||
"section.empty.color_strips": "No color strips yet. Click + to add one.",
|
||||
"section.empty.value_sources": "No value sources yet. Click + to add one.",
|
||||
"section.empty.sync_clocks": "No sync clocks yet. Click + to add one.",
|
||||
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
|
||||
"section.empty.automations": "No automations yet. Click + to add one.",
|
||||
"section.empty.scenes": "No scene presets yet. Click + to add one."
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"auth.toggle_password": "Показать/скрыть пароль",
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"api_key.login": "Войти",
|
||||
"displays.title": "Доступные Дисплеи",
|
||||
"displays.layout": "Дисплеи",
|
||||
"displays.information": "Информация о Дисплеях",
|
||||
@@ -291,6 +292,12 @@
|
||||
"device.health.offline": "Недоступен",
|
||||
"device.health.streaming_unreachable": "Недоступен во время стриминга",
|
||||
"device.health.checking": "Проверка...",
|
||||
"device.last_seen.label": "Последний раз",
|
||||
"device.last_seen.just_now": "только что",
|
||||
"device.last_seen.seconds": "%d с назад",
|
||||
"device.last_seen.minutes": "%d мин назад",
|
||||
"device.last_seen.hours": "%d ч назад",
|
||||
"device.last_seen.days": "%d д назад",
|
||||
"device.tutorial.start": "Начать обучение",
|
||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||
@@ -402,9 +409,11 @@
|
||||
"error.network": "Сетевая ошибка",
|
||||
"error.unknown": "Произошла ошибка",
|
||||
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
|
||||
"confirm.title": "Подтверждение Действия",
|
||||
"confirm.title": "Подтверждение",
|
||||
"confirm.yes": "Да",
|
||||
"confirm.no": "Нет",
|
||||
"confirm.stop_all": "Остановить все запущенные цели?",
|
||||
"confirm.turn_off_device": "Выключить это устройство?",
|
||||
"common.loading": "Загрузка...",
|
||||
"common.delete": "Удалить",
|
||||
"common.edit": "Редактировать",
|
||||
@@ -584,6 +593,7 @@
|
||||
"targets.section.color_strips": "Источники цветовых полос",
|
||||
"targets.section.targets": "Цели",
|
||||
"targets.section.specific_settings": "Специальные настройки",
|
||||
"targets.section.advanced": "Расширенные",
|
||||
"targets.add": "Добавить Цель",
|
||||
"targets.edit": "Редактировать Цель",
|
||||
"targets.loading": "Загрузка целей...",
|
||||
@@ -953,6 +963,11 @@
|
||||
"color_strip.gradient.preset.cool": "Холодный",
|
||||
"color_strip.gradient.preset.neon": "Неон",
|
||||
"color_strip.gradient.preset.pastel": "Пастельный",
|
||||
"color_strip.gradient.preset.save_button": "Сохранить как пресет…",
|
||||
"color_strip.gradient.preset.save_prompt": "Введите название пресета:",
|
||||
"color_strip.gradient.preset.saved": "Пресет сохранён",
|
||||
"color_strip.gradient.preset.deleted": "Пресет удалён",
|
||||
"color_strip.gradient.preset.apply": "Применить",
|
||||
"color_strip.animation": "Анимация",
|
||||
"color_strip.animation.type": "Эффект:",
|
||||
"color_strip.animation.type.hint": "Эффект анимации.",
|
||||
@@ -1043,6 +1058,15 @@
|
||||
"color_strip.notification.test.ok": "Уведомление отправлено",
|
||||
"color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника",
|
||||
"color_strip.notification.test.error": "Не удалось отправить уведомление",
|
||||
"color_strip.notification.history.title": "История уведомлений",
|
||||
"color_strip.notification.history.hint": "Последние ОС-уведомления, захваченные слушателем (новейшие сверху). До 50 записей.",
|
||||
"color_strip.notification.history.empty": "Уведомления ещё не захвачены",
|
||||
"color_strip.notification.history.unavailable": "Слушатель уведомлений ОС недоступен на этой платформе",
|
||||
"color_strip.notification.history.error": "Не удалось загрузить историю уведомлений",
|
||||
"color_strip.notification.history.refresh": "Обновить",
|
||||
"color_strip.notification.history.unknown_app": "Неизвестное приложение",
|
||||
"color_strip.notification.history.fired": "Потоков запущено",
|
||||
"color_strip.notification.history.filtered": "Потоков отфильтровано",
|
||||
"color_strip.test.title": "Предпросмотр",
|
||||
"color_strip.test.connecting": "Подключение...",
|
||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||
@@ -1188,6 +1212,7 @@
|
||||
"audio_source.type.mono": "Моно",
|
||||
"audio_source.device": "Аудиоустройство:",
|
||||
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
|
||||
"audio_source.refresh_devices": "Обновить устройства",
|
||||
"audio_source.parent": "Родительский источник:",
|
||||
"audio_source.parent.hint": "Многоканальный источник для извлечения канала",
|
||||
"audio_source.channel": "Канал:",
|
||||
@@ -1375,6 +1400,13 @@
|
||||
"search.group.value": "Источники значений",
|
||||
"search.group.scenes": "Пресеты сцен",
|
||||
"search.group.cspt": "Шаблоны обработки полос",
|
||||
"search.group.sync_clocks": "Синхронные часы",
|
||||
"search.group.actions": "Действия",
|
||||
"search.action.start": "Запустить",
|
||||
"search.action.stop": "Остановить",
|
||||
"search.action.activate": "Активировать",
|
||||
"search.action.enable": "Включить",
|
||||
"search.action.disable": "Отключить",
|
||||
"settings.backup.label": "Резервное копирование",
|
||||
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
|
||||
"settings.backup.button": "Скачать резервную копию",
|
||||
@@ -1388,7 +1420,15 @@
|
||||
"settings.restore.error": "Ошибка восстановления",
|
||||
"settings.restore.restarting": "Сервер перезапускается...",
|
||||
"settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.",
|
||||
"settings.restart_server": "Перезапустить сервер",
|
||||
"settings.restart_confirm": "Перезапустить сервер? Активные цели будут остановлены.",
|
||||
"settings.restarting": "Перезапуск сервера...",
|
||||
"settings.button.close": "Закрыть",
|
||||
"settings.log_level.label": "Уровень логирования",
|
||||
"settings.log_level.hint": "Изменить подробность логов сервера в реальном времени. DEBUG — максимум деталей, CRITICAL — только критические ошибки.",
|
||||
"settings.log_level.save": "Применить",
|
||||
"settings.log_level.saved": "Уровень логирования изменён",
|
||||
"settings.log_level.save_error": "Не удалось изменить уровень логирования",
|
||||
"settings.auto_backup.label": "Авто-бэкап",
|
||||
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
|
||||
"settings.auto_backup.enable": "Включить авто-бэкап",
|
||||
@@ -1407,6 +1447,32 @@
|
||||
"settings.saved_backups.delete": "Удалить",
|
||||
"settings.saved_backups.delete_confirm": "Удалить эту резервную копию?",
|
||||
"settings.saved_backups.delete_error": "Не удалось удалить копию",
|
||||
"settings.saved_backups.type.auto": "авто",
|
||||
"settings.saved_backups.type.manual": "ручной",
|
||||
"settings.mqtt.label": "MQTT",
|
||||
"settings.mqtt.hint": "Настройте подключение к MQTT-брокеру для условий и триггеров автоматизации.",
|
||||
"settings.mqtt.enabled": "Включить MQTT",
|
||||
"settings.mqtt.host_label": "Хост брокера",
|
||||
"settings.mqtt.port_label": "Порт",
|
||||
"settings.mqtt.username_label": "Имя пользователя",
|
||||
"settings.mqtt.password_label": "Пароль",
|
||||
"settings.mqtt.password_set_hint": "Пароль задан — оставьте пустым, чтобы сохранить",
|
||||
"settings.mqtt.client_id_label": "Идентификатор клиента",
|
||||
"settings.mqtt.base_topic_label": "Базовый топик",
|
||||
"settings.mqtt.save": "Сохранить настройки MQTT",
|
||||
"settings.mqtt.saved": "Настройки MQTT сохранены",
|
||||
"settings.mqtt.save_error": "Не удалось сохранить настройки MQTT",
|
||||
"settings.mqtt.error_host_required": "Требуется указать хост брокера",
|
||||
"settings.logs.label": "Журнал сервера",
|
||||
"settings.logs.hint": "Просмотр журнала сервера в реальном времени. Используйте фильтр для отображения нужных уровней.",
|
||||
"settings.logs.connect": "Подключить",
|
||||
"settings.logs.disconnect": "Отключить",
|
||||
"settings.logs.clear": "Очистить",
|
||||
"settings.logs.error": "Ошибка подключения к журналу",
|
||||
"settings.logs.filter.all": "Все уровни",
|
||||
"settings.logs.filter.info": "Info+",
|
||||
"settings.logs.filter.warning": "Warning+",
|
||||
"settings.logs.filter.error": "Только ошибки",
|
||||
"device.error.power_off_failed": "Не удалось выключить устройство",
|
||||
"device.removed": "Устройство удалено",
|
||||
"device.error.remove_failed": "Не удалось удалить устройство",
|
||||
@@ -1415,6 +1481,7 @@
|
||||
"device.error.required": "Пожалуйста, заполните все поля",
|
||||
"device.error.update": "Не удалось обновить устройство",
|
||||
"device.error.save": "Не удалось сохранить настройки",
|
||||
"device.error.clone_failed": "Не удалось клонировать устройство",
|
||||
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
|
||||
"device_discovery.added": "Устройство успешно добавлено",
|
||||
"device_discovery.error.add_failed": "Не удалось добавить устройство",
|
||||
@@ -1506,6 +1573,7 @@
|
||||
"sync_clock.resumed": "Часы возобновлены",
|
||||
"sync_clock.reset_done": "Часы сброшены на ноль",
|
||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
|
||||
"sync_clock.elapsed": "Прошло времени",
|
||||
"color_strip.clock": "Часы синхронизации:",
|
||||
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.",
|
||||
"graph.title": "Граф",
|
||||
@@ -1570,5 +1638,50 @@
|
||||
"graph.help.right_click_desc": "Отсоединить связь",
|
||||
"automation.enabled": "Автоматизация включена",
|
||||
"automation.disabled": "Автоматизация выключена",
|
||||
"scene_preset.activated": "Пресет активирован"
|
||||
"scene_preset.activated": "Пресет активирован",
|
||||
"scene_preset.used_by": "Используется в %d автоматизации(ях)",
|
||||
"settings.api_keys.label": "API-ключи",
|
||||
"settings.api_keys.hint": "API-ключи определяются в конфигурационном файле сервера (config.yaml). Отредактируйте файл и перезапустите сервер для применения изменений.",
|
||||
"settings.api_keys.empty": "API-ключи не настроены",
|
||||
"settings.api_keys.load_error": "Не удалось загрузить API-ключи",
|
||||
"settings.partial.label": "Частичный экспорт / импорт",
|
||||
"settings.partial.hint": "Экспортировать или импортировать один тип объектов. Импорт заменяет или объединяет данные и перезапускает сервер.",
|
||||
"settings.partial.store.devices": "Устройства",
|
||||
"settings.partial.store.output_targets": "LED-цели",
|
||||
"settings.partial.store.color_strip_sources": "Цветные полосы",
|
||||
"settings.partial.store.picture_sources": "Источники изображений",
|
||||
"settings.partial.store.audio_sources": "Аудио-источники",
|
||||
"settings.partial.store.audio_templates": "Аудио-шаблоны",
|
||||
"settings.partial.store.capture_templates": "Шаблоны захвата",
|
||||
"settings.partial.store.postprocessing_templates": "Шаблоны постобработки",
|
||||
"settings.partial.store.color_strip_processing_templates": "Шаблоны обработки полос",
|
||||
"settings.partial.store.pattern_templates": "Шаблоны паттернов",
|
||||
"settings.partial.store.value_sources": "Источники значений",
|
||||
"settings.partial.store.sync_clocks": "Синхронные часы",
|
||||
"settings.partial.store.automations": "Автоматизации",
|
||||
"settings.partial.store.scene_presets": "Пресеты сцен",
|
||||
"settings.partial.export_button": "Экспорт",
|
||||
"settings.partial.import_button": "Импорт из файла",
|
||||
"settings.partial.merge_label": "Объединить (добавить/перезаписать, сохранить существующие)",
|
||||
"settings.partial.export_success": "Экспорт выполнен",
|
||||
"settings.partial.export_error": "Ошибка экспорта",
|
||||
"settings.partial.import_success": "Импорт выполнен",
|
||||
"settings.partial.import_error": "Ошибка импорта",
|
||||
"settings.partial.import_confirm_replace": "Это ЗАМЕНИТ все данные {store} и перезапустит сервер. Продолжить?",
|
||||
"settings.partial.import_confirm_merge": "Это ОБЪЕДИНИТ данные {store} и перезапустит сервер. Продолжить?",
|
||||
"section.empty.devices": "Устройств пока нет. Нажмите + для добавления.",
|
||||
"section.empty.targets": "LED-целей пока нет. Нажмите + для добавления.",
|
||||
"section.empty.kc_targets": "Целей ключевых цветов пока нет. Нажмите + для добавления.",
|
||||
"section.empty.pattern_templates": "Шаблонов паттернов пока нет. Нажмите + для добавления.",
|
||||
"section.empty.picture_sources": "Источников пока нет. Нажмите + для добавления.",
|
||||
"section.empty.capture_templates": "Шаблонов захвата пока нет. Нажмите + для добавления.",
|
||||
"section.empty.pp_templates": "Шаблонов постобработки пока нет. Нажмите + для добавления.",
|
||||
"section.empty.audio_sources": "Аудио-источников пока нет. Нажмите + для добавления.",
|
||||
"section.empty.audio_templates": "Аудио-шаблонов пока нет. Нажмите + для добавления.",
|
||||
"section.empty.color_strips": "Цветных полос пока нет. Нажмите + для добавления.",
|
||||
"section.empty.value_sources": "Источников значений пока нет. Нажмите + для добавления.",
|
||||
"section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.",
|
||||
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
|
||||
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
|
||||
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления."
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"auth.toggle_password": "切换密码可见性",
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"api_key.login": "登录",
|
||||
"displays.title": "可用显示器",
|
||||
"displays.layout": "显示器",
|
||||
"displays.information": "显示器信息",
|
||||
@@ -291,6 +292,12 @@
|
||||
"device.health.offline": "离线",
|
||||
"device.health.streaming_unreachable": "流传输期间不可达",
|
||||
"device.health.checking": "检测中...",
|
||||
"device.last_seen.label": "最近检测",
|
||||
"device.last_seen.just_now": "刚刚",
|
||||
"device.last_seen.seconds": "%d秒前",
|
||||
"device.last_seen.minutes": "%d分钟前",
|
||||
"device.last_seen.hours": "%d小时前",
|
||||
"device.last_seen.days": "%d天前",
|
||||
"device.tutorial.start": "开始教程",
|
||||
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
||||
"device.tip.brightness": "滑动调节设备亮度",
|
||||
@@ -405,6 +412,8 @@
|
||||
"confirm.title": "确认操作",
|
||||
"confirm.yes": "是",
|
||||
"confirm.no": "否",
|
||||
"confirm.stop_all": "停止所有运行中的目标?",
|
||||
"confirm.turn_off_device": "关闭此设备?",
|
||||
"common.loading": "加载中...",
|
||||
"common.delete": "删除",
|
||||
"common.edit": "编辑",
|
||||
@@ -584,6 +593,7 @@
|
||||
"targets.section.color_strips": "色带源",
|
||||
"targets.section.targets": "目标",
|
||||
"targets.section.specific_settings": "特定设置",
|
||||
"targets.section.advanced": "高级",
|
||||
"targets.add": "添加目标",
|
||||
"targets.edit": "编辑目标",
|
||||
"targets.loading": "正在加载目标...",
|
||||
@@ -953,6 +963,11 @@
|
||||
"color_strip.gradient.preset.cool": "冷色",
|
||||
"color_strip.gradient.preset.neon": "霓虹",
|
||||
"color_strip.gradient.preset.pastel": "柔和",
|
||||
"color_strip.gradient.preset.save_button": "保存为预设…",
|
||||
"color_strip.gradient.preset.save_prompt": "输入预设名称:",
|
||||
"color_strip.gradient.preset.saved": "预设已保存",
|
||||
"color_strip.gradient.preset.deleted": "预设已删除",
|
||||
"color_strip.gradient.preset.apply": "应用",
|
||||
"color_strip.animation": "动画",
|
||||
"color_strip.animation.type": "效果:",
|
||||
"color_strip.animation.type.hint": "要应用的动画效果。",
|
||||
@@ -1043,6 +1058,15 @@
|
||||
"color_strip.notification.test.ok": "通知已发送",
|
||||
"color_strip.notification.test.no_streams": "此源没有运行中的流",
|
||||
"color_strip.notification.test.error": "发送通知失败",
|
||||
"color_strip.notification.history.title": "通知历史",
|
||||
"color_strip.notification.history.hint": "监听器捕获的最近OS通知(最新在前),最多50条。",
|
||||
"color_strip.notification.history.empty": "尚未捕获任何通知",
|
||||
"color_strip.notification.history.unavailable": "此平台不支持OS通知监听器",
|
||||
"color_strip.notification.history.error": "加载通知历史失败",
|
||||
"color_strip.notification.history.refresh": "刷新",
|
||||
"color_strip.notification.history.unknown_app": "未知应用",
|
||||
"color_strip.notification.history.fired": "触发的流数量",
|
||||
"color_strip.notification.history.filtered": "过滤的流数量",
|
||||
"color_strip.test.title": "预览测试",
|
||||
"color_strip.test.connecting": "连接中...",
|
||||
"color_strip.test.error": "无法连接到预览流",
|
||||
@@ -1188,6 +1212,7 @@
|
||||
"audio_source.type.mono": "单声道",
|
||||
"audio_source.device": "音频设备:",
|
||||
"audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。",
|
||||
"audio_source.refresh_devices": "刷新设备",
|
||||
"audio_source.parent": "父源:",
|
||||
"audio_source.parent.hint": "要从中提取通道的多声道源",
|
||||
"audio_source.channel": "通道:",
|
||||
@@ -1375,6 +1400,13 @@
|
||||
"search.group.value": "值源",
|
||||
"search.group.scenes": "场景预设",
|
||||
"search.group.cspt": "色带处理模板",
|
||||
"search.group.sync_clocks": "同步时钟",
|
||||
"search.group.actions": "操作",
|
||||
"search.action.start": "启动",
|
||||
"search.action.stop": "停止",
|
||||
"search.action.activate": "激活",
|
||||
"search.action.enable": "启用",
|
||||
"search.action.disable": "禁用",
|
||||
"settings.backup.label": "备份配置",
|
||||
"settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
|
||||
"settings.backup.button": "下载备份",
|
||||
@@ -1388,7 +1420,15 @@
|
||||
"settings.restore.error": "恢复失败",
|
||||
"settings.restore.restarting": "服务器正在重启...",
|
||||
"settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。",
|
||||
"settings.restart_server": "重启服务器",
|
||||
"settings.restart_confirm": "重启服务器?活跃的目标将被停止。",
|
||||
"settings.restarting": "正在重启服务器...",
|
||||
"settings.button.close": "关闭",
|
||||
"settings.log_level.label": "日志级别",
|
||||
"settings.log_level.hint": "实时更改服务器日志详细程度。DEBUG 显示最多细节;CRITICAL 仅显示致命错误。",
|
||||
"settings.log_level.save": "应用",
|
||||
"settings.log_level.saved": "日志级别已更改",
|
||||
"settings.log_level.save_error": "更改日志级别失败",
|
||||
"settings.auto_backup.label": "自动备份",
|
||||
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
|
||||
"settings.auto_backup.enable": "启用自动备份",
|
||||
@@ -1407,6 +1447,32 @@
|
||||
"settings.saved_backups.delete": "删除",
|
||||
"settings.saved_backups.delete_confirm": "删除此备份文件?",
|
||||
"settings.saved_backups.delete_error": "删除备份失败",
|
||||
"settings.saved_backups.type.auto": "自动",
|
||||
"settings.saved_backups.type.manual": "手动",
|
||||
"settings.mqtt.label": "MQTT",
|
||||
"settings.mqtt.hint": "配置 MQTT 代理连接,用于自动化条件和触发器。",
|
||||
"settings.mqtt.enabled": "启用 MQTT",
|
||||
"settings.mqtt.host_label": "代理主机",
|
||||
"settings.mqtt.port_label": "端口",
|
||||
"settings.mqtt.username_label": "用户名",
|
||||
"settings.mqtt.password_label": "密码",
|
||||
"settings.mqtt.password_set_hint": "已设置密码 — 留空以保留",
|
||||
"settings.mqtt.client_id_label": "客户端 ID",
|
||||
"settings.mqtt.base_topic_label": "基础主题",
|
||||
"settings.mqtt.save": "保存 MQTT 设置",
|
||||
"settings.mqtt.saved": "MQTT 设置已保存",
|
||||
"settings.mqtt.save_error": "保存 MQTT 设置失败",
|
||||
"settings.mqtt.error_host_required": "代理主机不能为空",
|
||||
"settings.logs.label": "服务器日志",
|
||||
"settings.logs.hint": "实时查看服务器日志。使用过滤器显示所需的日志级别。",
|
||||
"settings.logs.connect": "连接",
|
||||
"settings.logs.disconnect": "断开",
|
||||
"settings.logs.clear": "清除",
|
||||
"settings.logs.error": "日志查看器连接失败",
|
||||
"settings.logs.filter.all": "所有级别",
|
||||
"settings.logs.filter.info": "Info+",
|
||||
"settings.logs.filter.warning": "Warning+",
|
||||
"settings.logs.filter.error": "仅错误",
|
||||
"device.error.power_off_failed": "关闭设备失败",
|
||||
"device.removed": "设备已移除",
|
||||
"device.error.remove_failed": "移除设备失败",
|
||||
@@ -1415,6 +1481,7 @@
|
||||
"device.error.required": "请填写所有字段",
|
||||
"device.error.update": "更新设备失败",
|
||||
"device.error.save": "保存设置失败",
|
||||
"device.error.clone_failed": "克隆设备失败",
|
||||
"device_discovery.error.fill_all_fields": "请填写所有字段",
|
||||
"device_discovery.added": "设备添加成功",
|
||||
"device_discovery.error.add_failed": "添加设备失败",
|
||||
@@ -1506,6 +1573,7 @@
|
||||
"sync_clock.resumed": "时钟已恢复",
|
||||
"sync_clock.reset_done": "时钟已重置为零",
|
||||
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
|
||||
"sync_clock.elapsed": "已用时间",
|
||||
"color_strip.clock": "同步时钟:",
|
||||
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。",
|
||||
"graph.title": "图表",
|
||||
@@ -1570,5 +1638,50 @@
|
||||
"graph.help.right_click_desc": "断开连接",
|
||||
"automation.enabled": "自动化已启用",
|
||||
"automation.disabled": "自动化已禁用",
|
||||
"scene_preset.activated": "预设已激活"
|
||||
"scene_preset.activated": "预设已激活",
|
||||
"scene_preset.used_by": "被 %d 个自动化使用",
|
||||
"settings.api_keys.label": "API 密钥",
|
||||
"settings.api_keys.hint": "API 密钥在服务器配置文件 (config.yaml) 中定义。编辑文件并重启服务器以应用更改。",
|
||||
"settings.api_keys.empty": "未配置 API 密钥",
|
||||
"settings.api_keys.load_error": "加载 API 密钥失败",
|
||||
"settings.partial.label": "部分导出 / 导入",
|
||||
"settings.partial.hint": "导出或导入单个实体类型。导入会替换或合并现有数据并重启服务器。",
|
||||
"settings.partial.store.devices": "设备",
|
||||
"settings.partial.store.output_targets": "LED 目标",
|
||||
"settings.partial.store.color_strip_sources": "色带",
|
||||
"settings.partial.store.picture_sources": "图像源",
|
||||
"settings.partial.store.audio_sources": "音频源",
|
||||
"settings.partial.store.audio_templates": "音频模板",
|
||||
"settings.partial.store.capture_templates": "捕获模板",
|
||||
"settings.partial.store.postprocessing_templates": "后处理模板",
|
||||
"settings.partial.store.color_strip_processing_templates": "CSS 处理模板",
|
||||
"settings.partial.store.pattern_templates": "图案模板",
|
||||
"settings.partial.store.value_sources": "值源",
|
||||
"settings.partial.store.sync_clocks": "同步时钟",
|
||||
"settings.partial.store.automations": "自动化",
|
||||
"settings.partial.store.scene_presets": "场景预设",
|
||||
"settings.partial.export_button": "导出",
|
||||
"settings.partial.import_button": "从文件导入",
|
||||
"settings.partial.merge_label": "合并(添加/覆盖,保留现有)",
|
||||
"settings.partial.export_success": "导出成功",
|
||||
"settings.partial.export_error": "导出失败",
|
||||
"settings.partial.import_success": "导入成功",
|
||||
"settings.partial.import_error": "导入失败",
|
||||
"settings.partial.import_confirm_replace": "这将替换所有 {store} 数据并重启服务器。继续吗?",
|
||||
"settings.partial.import_confirm_merge": "这将合并 {store} 数据并重启服务器。继续吗?",
|
||||
"section.empty.devices": "暂无设备。点击 + 添加。",
|
||||
"section.empty.targets": "暂无 LED 目标。点击 + 添加。",
|
||||
"section.empty.kc_targets": "暂无键色目标。点击 + 添加。",
|
||||
"section.empty.pattern_templates": "暂无图案模板。点击 + 添加。",
|
||||
"section.empty.picture_sources": "暂无源。点击 + 添加。",
|
||||
"section.empty.capture_templates": "暂无捕获模板。点击 + 添加。",
|
||||
"section.empty.pp_templates": "暂无后处理模板。点击 + 添加。",
|
||||
"section.empty.audio_sources": "暂无音频源。点击 + 添加。",
|
||||
"section.empty.audio_templates": "暂无音频模板。点击 + 添加。",
|
||||
"section.empty.color_strips": "暂无色带。点击 + 添加。",
|
||||
"section.empty.value_sources": "暂无值源。点击 + 添加。",
|
||||
"section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。",
|
||||
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
|
||||
"section.empty.automations": "暂无自动化。点击 + 添加。",
|
||||
"section.empty.scenes": "暂无场景预设。点击 + 添加。"
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
{% include 'modals/target-editor.html' %}
|
||||
{% include 'modals/css-editor.html' %}
|
||||
{% include 'modals/test-css-source.html' %}
|
||||
{% include 'modals/notification-history.html' %}
|
||||
{% include 'modals/kc-editor.html' %}
|
||||
{% include 'modals/pattern-template.html' %}
|
||||
{% include 'modals/api-key.html' %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button type="submit" class="btn btn-icon btn-primary" title="Login" data-i18n-aria-label="aria.save">✓</button>
|
||||
<button type="submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +44,12 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small>
|
||||
<select id="audio-source-device">
|
||||
<!-- populated dynamically -->
|
||||
</select>
|
||||
<div class="select-with-action">
|
||||
<select id="audio-source-device">
|
||||
<!-- populated dynamically -->
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="audio-source-refresh-devices" onclick="refreshAudioDevices()" data-i18n-title="audio_source.refresh_devices" title="Refresh devices">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,16 +81,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Edge test toggle zones (outside container border, in tick area) -->
|
||||
<div class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></div>
|
||||
<div class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></div>
|
||||
<div class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></div>
|
||||
<div class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></div>
|
||||
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
|
||||
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
|
||||
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
|
||||
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
|
||||
|
||||
<!-- Corner start position buttons -->
|
||||
<div class="preview-corner corner-top-left" onclick="setStartPosition('top_left')">●</div>
|
||||
<div class="preview-corner corner-top-right" onclick="setStartPosition('top_right')">●</div>
|
||||
<div class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')">●</div>
|
||||
<div class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')">●</div>
|
||||
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')">●</button>
|
||||
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')">●</button>
|
||||
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')">●</button>
|
||||
|
||||
<!-- Canvas overlay for ticks, arrows, start label -->
|
||||
<canvas id="calibration-preview-canvas"></canvas>
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tutorial Overlay -->
|
||||
<div id="tutorial-overlay" class="tutorial-overlay">
|
||||
<div id="calibration-tutorial-overlay" class="tutorial-overlay">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
<div class="tutorial-ring"></div>
|
||||
<div class="tutorial-tooltip">
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<div id="confirm-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
|
||||
<div class="modal-content" style="max-width: 450px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="confirm-modal-title">Confirm Action</h2>
|
||||
<h2 id="confirm-modal-title" data-i18n="confirm.title">Confirm Action</h2>
|
||||
<button class="modal-close-btn" onclick="closeConfirmModal(false)" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-message" class="modal-description"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
<button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)" data-i18n="confirm.no">No</button>
|
||||
<button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)" data-i18n="confirm.yes">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preset.hint">Load a predefined gradient palette. Selecting a preset replaces the current stops.</small>
|
||||
<select id="css-editor-gradient-preset" onchange="applyGradientPreset(this.value)">
|
||||
<select id="css-editor-gradient-preset" onchange="onGradientPresetChange(this.value)">
|
||||
<option value="" data-i18n="color_strip.gradient.preset.custom">— Custom —</option>
|
||||
<option value="rainbow" data-i18n="color_strip.gradient.preset.rainbow">Rainbow</option>
|
||||
<option value="sunset" data-i18n="color_strip.gradient.preset.sunset">Sunset</option>
|
||||
@@ -124,6 +124,12 @@
|
||||
<option value="neon" data-i18n="color_strip.gradient.preset.neon">Neon</option>
|
||||
<option value="pastel" data-i18n="color_strip.gradient.preset.pastel">Pastel</option>
|
||||
</select>
|
||||
<div style="margin-top:6px;">
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
onclick="promptAndSaveGradientPreset()"
|
||||
data-i18n="color_strip.gradient.preset.save_button">Save as preset…</button>
|
||||
</div>
|
||||
<div id="css-editor-custom-presets-list" class="custom-presets-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- OS Notification History Modal -->
|
||||
<div id="notification-history-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="notification-history-modal-title" style="display:none">
|
||||
<div class="modal-content" style="max-width:520px;width:100%">
|
||||
<div class="modal-header">
|
||||
<h2 id="notification-history-modal-title" data-i18n="color_strip.notification.history.title">Notification History</h2>
|
||||
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
|
||||
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
|
||||
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="refreshNotificationHistory()" data-i18n="color_strip.notification.history.refresh">Refresh</button>
|
||||
<button class="btn btn-secondary" onclick="closeNotificationHistory()" data-i18n="settings.button.cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,6 +6,16 @@
|
||||
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- API Keys section (read-only) -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.api_keys.label">API Keys</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
|
||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
@@ -27,6 +37,43 @@
|
||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Partial Export/Import section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.partial.label">Partial Export / Import</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<select id="settings-partial-store" style="flex:1">
|
||||
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
|
||||
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
|
||||
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
|
||||
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
|
||||
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
|
||||
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
|
||||
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
|
||||
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
|
||||
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
|
||||
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
|
||||
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
|
||||
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
|
||||
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
|
||||
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="settings-partial-merge">
|
||||
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
|
||||
</div>
|
||||
|
||||
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
@@ -73,10 +120,109 @@
|
||||
<div id="saved-backups-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- MQTT section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="mqtt-enabled">
|
||||
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
|
||||
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
|
||||
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
|
||||
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
|
||||
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
|
||||
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
|
||||
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||
</div>
|
||||
|
||||
<!-- Server Logs section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.logs.label">Server Logs</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
|
||||
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
|
||||
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
|
||||
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
|
||||
<option value="all" data-i18n="settings.logs.filter.all">All</option>
|
||||
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
|
||||
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
|
||||
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<pre id="log-viewer-output" class="log-viewer-output"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Log Level section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.log_level.label">Log Level</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<select id="settings-log-level" style="flex:1">
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="setLogLevel()" data-i18n="settings.log_level.save">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.restart_server">Restart Server</label>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeSettingsModal()" title="Close" data-i18n-title="settings.button.close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,33 +61,33 @@
|
||||
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-brightness-threshold-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-brightness-threshold">
|
||||
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
|
||||
<span id="target-editor-brightness-threshold-value">0</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
|
||||
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-adaptive-fps-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="target-editor-adaptive-fps">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="form-collapse" id="target-editor-device-settings">
|
||||
<summary data-i18n="targets.section.specific_settings">Specific Settings</summary>
|
||||
<details class="form-collapse" id="target-editor-advanced-settings">
|
||||
<summary data-i18n="targets.section.advanced">Advanced</summary>
|
||||
<div class="form-collapse-body">
|
||||
<div class="form-group" id="target-editor-brightness-threshold-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-brightness-threshold">
|
||||
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
|
||||
<span id="target-editor-brightness-threshold-value">0</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
|
||||
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-adaptive-fps-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="target-editor-adaptive-fps">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="target-editor-protocol-group">
|
||||
<div class="label-row">
|
||||
<label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label>
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
<option value="square" data-i18n="value_source.waveform.square">Square</option>
|
||||
<option value="sawtooth" data-i18n="value_source.waveform.sawtooth">Sawtooth</option>
|
||||
</select>
|
||||
<canvas id="value-source-waveform-preview" width="200" height="60"
|
||||
style="display:block;margin-top:8px;border-radius:6px;background:var(--surface-2,#1e1e2e);width:100%;max-width:200px;height:60px;"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -4,5 +4,16 @@ from .file_ops import atomic_write_json
|
||||
from .logger import setup_logging, get_logger
|
||||
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
|
||||
from .timer import high_resolution_timer
|
||||
from .log_broadcaster import broadcaster as log_broadcaster, install_broadcast_handler
|
||||
|
||||
__all__ = ["atomic_write_json", "setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"]
|
||||
__all__ = [
|
||||
"atomic_write_json",
|
||||
"setup_logging",
|
||||
"get_logger",
|
||||
"get_monitor_names",
|
||||
"get_monitor_name",
|
||||
"get_monitor_refresh_rates",
|
||||
"high_resolution_timer",
|
||||
"log_broadcaster",
|
||||
"install_broadcast_handler",
|
||||
]
|
||||
|
||||
118
server/src/wled_controller/utils/log_broadcaster.py
Normal file
118
server/src/wled_controller/utils/log_broadcaster.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Log broadcaster: in-memory ring buffer + WebSocket fan-out for live log tailing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Set
|
||||
|
||||
# Maximum number of log records kept in memory
|
||||
_BACKLOG_SIZE = 500
|
||||
|
||||
# A simple text formatter used by the broadcast handler
|
||||
_formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
|
||||
|
||||
class LogBroadcaster:
|
||||
"""Singleton that buffers recent log lines and fans them out to WS clients."""
|
||||
|
||||
def __init__(self, maxlen: int = _BACKLOG_SIZE) -> None:
|
||||
self._backlog: deque[str] = deque(maxlen=maxlen)
|
||||
self._clients: Set[asyncio.Queue] = set()
|
||||
# The async event loop where send_to_clients() is scheduled.
|
||||
# Set lazily on the first WS connection (inside the async context).
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Called from the logging.Handler (any thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def emit(self, line: str) -> None:
|
||||
"""Append *line* to the backlog and notify all connected WS clients.
|
||||
|
||||
Safe to call from any thread — it schedules the async notification on
|
||||
the server's event loop without blocking.
|
||||
"""
|
||||
self._backlog.append(line)
|
||||
if self._clients and self._loop is not None:
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._enqueue_line, line)
|
||||
except RuntimeError:
|
||||
# Loop closed / not running — silently drop
|
||||
pass
|
||||
|
||||
def _enqueue_line(self, line: str) -> None:
|
||||
"""Push *line* onto every connected client's queue (called from the event loop)."""
|
||||
dead: Set[asyncio.Queue] = set()
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait(line)
|
||||
except asyncio.QueueFull:
|
||||
dead.add(q)
|
||||
self._clients -= dead
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Called from the async WS handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe(self) -> "asyncio.Queue[str]":
|
||||
"""Register a new WS client and return its private line queue."""
|
||||
if self._loop is None:
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
q: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
|
||||
self._clients.add(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: "asyncio.Queue[str]") -> None:
|
||||
"""Remove a WS client queue."""
|
||||
self._clients.discard(q)
|
||||
|
||||
def get_backlog(self) -> list[str]:
|
||||
"""Return a snapshot of the current backlog (oldest → newest)."""
|
||||
return list(self._backlog)
|
||||
|
||||
def ensure_loop(self) -> None:
|
||||
"""Capture the running event loop if not already stored.
|
||||
|
||||
Call this once from an async context (e.g. the WS endpoint) so that
|
||||
thread-safe scheduling works correctly even before the first client
|
||||
connects.
|
||||
"""
|
||||
if self._loop is None:
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
broadcaster = LogBroadcaster()
|
||||
|
||||
|
||||
class BroadcastLogHandler(logging.Handler):
|
||||
"""A logging.Handler that pushes formatted records into the LogBroadcaster."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setFormatter(_formatter)
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None: # type: ignore[override]
|
||||
try:
|
||||
line = self.format(record)
|
||||
broadcaster.emit(line)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def install_broadcast_handler() -> None:
|
||||
"""Attach BroadcastLogHandler to the root logger (idempotent)."""
|
||||
root = logging.getLogger()
|
||||
# Avoid double-installation on hot reload / re-import
|
||||
for h in root.handlers:
|
||||
if isinstance(h, BroadcastLogHandler):
|
||||
return
|
||||
handler = BroadcastLogHandler()
|
||||
handler.setLevel(logging.DEBUG)
|
||||
root.addHandler(handler)
|
||||
Reference in New Issue
Block a user