From 304fa24389c3fdba945518660f2a5a9674875766 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 16 Mar 2026 18:46:38 +0300 Subject: [PATCH] Comprehensive WebUI review: 41 UX/feature/CSS improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- TODO.md | 61 +++ .../api/routes/output_targets.py | 60 +++ .../src/wled_controller/api/routes/system.py | 318 +++++++++++++++- .../api/schemas/output_targets.py | 14 + .../src/wled_controller/api/schemas/system.py | 40 ++ server/src/wled_controller/main.py | 3 +- .../src/wled_controller/static/css/base.css | 24 ++ .../src/wled_controller/static/css/cards.css | 41 ++- .../wled_controller/static/css/components.css | 52 +-- .../src/wled_controller/static/css/layout.css | 22 +- .../src/wled_controller/static/css/mobile.css | 8 + .../src/wled_controller/static/css/modal.css | 210 ++++++++++- .../wled_controller/static/css/streams.css | 126 +++---- server/src/wled_controller/static/js/app.js | 32 +- .../static/js/core/card-sections.js | 29 +- .../static/js/core/color-picker.js | 2 +- .../static/js/core/command-palette.js | 93 ++++- .../static/js/core/filter-list.js | 5 +- .../static/js/features/audio-sources.js | 12 + .../static/js/features/automations.js | 2 +- .../static/js/features/color-strips.js | 319 +++++++++++++++- .../static/js/features/css-gradient-editor.js | 35 ++ .../static/js/features/dashboard.js | 4 +- .../static/js/features/device-discovery.js | 58 ++- .../static/js/features/devices.js | 25 +- .../static/js/features/scene-presets.js | 7 +- .../static/js/features/settings.js | 348 +++++++++++++++++- .../static/js/features/streams.js | 31 +- .../static/js/features/sync-clocks.js | 11 + .../static/js/features/targets.js | 20 +- .../static/js/features/tutorials.js | 2 +- .../static/js/features/value-sources.js | 88 ++++- .../wled_controller/static/locales/en.json | 115 +++++- .../wled_controller/static/locales/ru.json | 117 +++++- .../wled_controller/static/locales/zh.json | 115 +++++- .../src/wled_controller/templates/index.html | 1 + .../templates/modals/api-key.html | 2 +- .../templates/modals/audio-source-editor.html | 9 +- .../templates/modals/calibration.html | 18 +- .../templates/modals/confirm.html | 6 +- .../templates/modals/css-editor.html | 8 +- .../modals/notification-history.html | 18 + .../templates/modals/settings.html | 148 +++++++- .../templates/modals/target-editor.html | 52 +-- .../templates/modals/value-source-editor.html | 2 + server/src/wled_controller/utils/__init__.py | 13 +- .../wled_controller/utils/log_broadcaster.py | 118 ++++++ 47 files changed, 2594 insertions(+), 250 deletions(-) create mode 100644 server/src/wled_controller/templates/modals/notification-history.html create mode 100644 server/src/wled_controller/utils/log_broadcaster.py diff --git a/TODO.md b/TODO.md index bf7132a..e472cd2 100644 --- a/TODO.md +++ b/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() diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index dab3c36..b8b342f 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -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"]) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index a1963b6..f023ffe 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -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=``. 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) diff --git a/server/src/wled_controller/api/schemas/output_targets.py b/server/src/wled_controller/api/schemas/output_targets.py index de37aaf..96c2359 100644 --- a/server/src/wled_controller/api/schemas/output_targets.py +++ b/server/src/wled_controller/api/schemas/output_targets.py @@ -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.""" diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 2ed16df..219e038 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -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)") diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 50c5f78..e762d90 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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 diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index 0a9ceee..4e9996b 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -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; } } diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 36498aa..2ed5d44 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -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, diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index d3c19dd..e1281b1 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -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; diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 20952f3..3e2526d 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -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, diff --git a/server/src/wled_controller/static/css/mobile.css b/server/src/wled_controller/static/css/mobile.css index b3a72a3..33600dd 100644 --- a/server/src/wled_controller/static/css/mobile.css +++ b/server/src/wled_controller/static/css/mobile.css @@ -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%; } } diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 25659ce..2b43c09 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -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; +} + diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index ef658a5..87fc916 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -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 { diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 1674aba..9177ba3 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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 ─── diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index 75be11f..3f8b323 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -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 { ? `
+
` : ''; + const emptyState = (count === 0 && this.emptyKey) + ? `
${t(this.emptyKey)}
` + : ''; + return `
@@ -99,7 +105,7 @@ export class CardSection {
- ${cardsHtml} + ${emptyState}${cardsHtml} ${addCard}
`; @@ -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 = `${t(this.emptyKey)}`; + 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(); diff --git a/server/src/wled_controller/static/js/core/color-picker.js b/server/src/wled_controller/static/js/core/color-picker.js index 828b414..3eb5696 100644 --- a/server/src/wled_controller/static/js/core/color-picker.js +++ b/server/src/wled_controller/static/js/core/color-picker.js @@ -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 ``; + return ``; }).join(''); const resetBtn = showReset diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js index c6b24a1..bb0e164 100644 --- a/server/src/wled_controller/static/js/core/command-palette.js +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -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 += `
${t('search.group.' + group)}
`; 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 += `
` + + const actionClass = item.action ? ' cp-action-item' : ''; + html += `
` + `${item.icon}` + `${escapeHtml(item.name)}` + (item.running ? '' : '') + @@ -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) { diff --git a/server/src/wled_controller/static/js/core/filter-list.js b/server/src/wled_controller/static/js/core/filter-list.js index f0f9e0b..a385cca 100644 --- a/server/src/wled_controller/static/js/core/filter-list.js +++ b/server/src/wled_controller/static/js/core/filter-list.js @@ -148,10 +148,11 @@ export class FilterListManager { if (opt.type === 'bool') { const checked = currentVal === true || currentVal === 'true'; html += `
-
`; } else if (opt.type === 'select' && Array.isArray(opt.choices)) { diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js index 5034139..f6423ae 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.js +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -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 = {}; diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 216b482..3750b5c 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -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 ───────────────────────────────── */ diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 62c5c03..d448476 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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 `
+ ${strip} + ${safeName} + + +
`; + }).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 ` -
+
+ ${ICON_REFRESH} + `, @@ -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', diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index 788f7ec..734c643 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -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() : ''; diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js index 3e4b2fa..bb38cb8 100644 --- a/server/src/wled_controller/static/js/features/settings.js +++ b/server/src/wled_controller/static/js/features/settings.js @@ -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 = '
' + - `
${t('settings.restore.restarting')}
`; + `
${msg}
`; // 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 + ? `${t('settings.saved_backups.type.auto')}` + : `${t('settings.saved_backups.type.manual')}`; return `
+ ${typeBadge}
${date} - ${sizeKB} KB + ${sizeStr}
@@ -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 = `
${t('settings.api_keys.load_error')}
`; + return; + } + const data = await resp.json(); + if (data.count === 0) { + container.innerHTML = `
${t('settings.api_keys.empty')}
`; + return; + } + container.innerHTML = data.keys.map(k => + `
+ ${k.label} + ${k.masked} +
` + ).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'); + } +} diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 732b34f..ad4c0f9 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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() { diff --git a/server/src/wled_controller/static/js/features/sync-clocks.js b/server/src/wled_controller/static/js/features/sync-clocks.js index 5f032ad..decddae 100644 --- a/server/src/wled_controller/static/js/features/sync-clocks.js +++ b/server/src/wled_controller/static/js/features/sync-clocks.js @@ -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) {
${statusIcon} ${statusLabel} ${ICON_CLOCK} ${clock.speed}x + ${elapsedLabel ? `⏱ ${elapsedLabel}` : ''}
${renderTagChips(clock.tags)} ${clock.description ? `
${escapeHtml(clock.description)}
` : ''}`, diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index a9a7d4a..2712df7 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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: `` }); -const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `` }); -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: `` }); +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: `` }); +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'); } diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js index b2e0d03..4898e85 100644 --- a/server/src/wled_controller/static/js/features/tutorials.js +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -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) => { diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index f95ca86..13992c8 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -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'; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index d263602..cf25c8c 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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." } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 43e526c..4a24c10 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Пресетов сцен пока нет. Нажмите + для добавления." } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ce4900e..e9624bb 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "暂无场景预设。点击 + 添加。" } diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index be3b1c2..6783324 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -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' %} diff --git a/server/src/wled_controller/templates/modals/api-key.html b/server/src/wled_controller/templates/modals/api-key.html index 315b0b2..b02bba0 100644 --- a/server/src/wled_controller/templates/modals/api-key.html +++ b/server/src/wled_controller/templates/modals/api-key.html @@ -33,7 +33,7 @@
diff --git a/server/src/wled_controller/templates/modals/audio-source-editor.html b/server/src/wled_controller/templates/modals/audio-source-editor.html index 9ec825f..5173bc8 100644 --- a/server/src/wled_controller/templates/modals/audio-source-editor.html +++ b/server/src/wled_controller/templates/modals/audio-source-editor.html @@ -44,9 +44,12 @@
- +
+ + +
diff --git a/server/src/wled_controller/templates/modals/calibration.html b/server/src/wled_controller/templates/modals/calibration.html index 9647bcc..adb5a01 100644 --- a/server/src/wled_controller/templates/modals/calibration.html +++ b/server/src/wled_controller/templates/modals/calibration.html @@ -81,16 +81,16 @@
-
-
-
-
+ + + + -
-
-
-
+ + + + @@ -141,7 +141,7 @@ -
+
diff --git a/server/src/wled_controller/templates/modals/confirm.html b/server/src/wled_controller/templates/modals/confirm.html index acdb77b..27ff8de 100644 --- a/server/src/wled_controller/templates/modals/confirm.html +++ b/server/src/wled_controller/templates/modals/confirm.html @@ -2,15 +2,15 @@ diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 515926e..81d2bd7 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -109,7 +109,7 @@
- @@ -124,6 +124,12 @@ +
+ +
+
diff --git a/server/src/wled_controller/templates/modals/notification-history.html b/server/src/wled_controller/templates/modals/notification-history.html new file mode 100644 index 0000000..fd0e78a --- /dev/null +++ b/server/src/wled_controller/templates/modals/notification-history.html @@ -0,0 +1,18 @@ + + diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index f0401a5..4d614fa 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -6,6 +6,16 @@
diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index f4ddcf2..7ac0646 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -61,33 +61,33 @@
-
-
- - -
- - -
- -
-
- - -
- - -
- -
- Specific Settings +
+ Advanced
+
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+
diff --git a/server/src/wled_controller/templates/modals/value-source-editor.html b/server/src/wled_controller/templates/modals/value-source-editor.html index 78718b5..f798dfa 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -66,6 +66,8 @@ +
diff --git a/server/src/wled_controller/utils/__init__.py b/server/src/wled_controller/utils/__init__.py index 7569261..42466a5 100644 --- a/server/src/wled_controller/utils/__init__.py +++ b/server/src/wled_controller/utils/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/utils/log_broadcaster.py b/server/src/wled_controller/utils/log_broadcaster.py new file mode 100644 index 0000000..822d89c --- /dev/null +++ b/server/src/wled_controller/utils/log_broadcaster.py @@ -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)