Compare commits

..

5 Commits

Author SHA1 Message Date
05152a0f51 Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes
- Settings modal split into 3 tabs: General, Backup, MQTT
- Log viewer moved to full-screen overlay with compact toolbar
- External URL setting: API endpoints + UI for configuring server domain
  used in webhook/WS URLs instead of auto-detected local IP
- Sources tab tree restructured: Picture Source (Screen Capture/Static/
  Processed sub-groups), Color Strip, Audio, Utility
- TreeNav extended to support nested groups (3-level tree)
- Audio tab split into Sources and Templates sub-tabs
- Fix audio template test: device picker now filters by engine type
  (was showing WASAPI indices for sounddevice templates)
- Audio template test device picker disabled during active test
- Rename "Input Source" to "Source" in CSS test preview (en/ru/zh)
- Fix i18n: log filter/level items deferred to avoid stale t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:16:57 +03:00
191c988cf9 Graph node FPS hover tooltip, full names, no native SVG tooltips
Graph editor:
- Floating FPS tooltip on hover over running output_target nodes (300ms delay)
- Shows errors, uptime, and FPS sparkline seeded from server metrics history
- Tooltip positioned below node with fade-in/out animation
- Uses pointerover/pointerout with relatedTarget check to prevent flicker
- Fixed-width tooltip (200px) with monospace values to prevent layout shift
- Node titles show full names (removed truncate), no native SVG <title> tooltips

Documentation:
- Added duration/numeric formatting conventions to contexts/frontend.md
- Added node hover tooltip docs to contexts/graph-editor.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:45:59 +03:00
afd4a3bc05 Override blend mode, FPS sparkline, fix api_input persistence
New features:
- Override composite blend mode: per-pixel alpha from brightness
  (black=transparent, bright=opaque). Ideal for API input over effects.
- API input test preview FPS chart uses shared createFpsSparkline
  (same look as target card charts)

Fixes:
- Fix api_input source not surviving server restart: from_dict was
  still passing removed led_count field to constructor
- Fix composite layer brightness/processing selectors not aligned:
  labels get fixed width, selects fill remaining space
- Fix CSPT input selector showing in non-CSPT CSS test mode
- Fix test modal LED/FPS controls showing for api_input sources
- Server only sends test WS frames when api_input push_generation changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:12:57 +03:00
be356f30eb Fix HAOS light color reverting after timeout
When HA sets a color via turn_on, also update the source's fallback_color
to match. This way when the api_input timeout fires (default 5s), the
stream reverts to the same color instead of black. turn_off resets
fallback to [0,0,0].

Added coordinator.update_source() for PUT /color-strip-sources/{id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:52:50 +03:00
8a6ffca446 Rework API input CSS: segments, remove led_count, HAOS light, test preview
API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements

Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)

HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:47:42 +03:00
38 changed files with 1829 additions and 404 deletions

View File

@@ -219,6 +219,31 @@ When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import`
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow. See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
## Duration & Numeric Formatting
### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
- `font-family: var(--font-mono, monospace)` — equal-width characters
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
- Fixed `width` or `min-width` on the value container
- `text-align: right` to anchor the growing edge
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
### FPS sparkline charts
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.js`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
## Visual Graph Editor ## Visual Graph Editor
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions. See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.

View File

@@ -88,6 +88,14 @@ The filter bar (toggled with F or toolbar button) filters nodes by name/kind/sub
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage. Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
## Node hover FPS tooltip
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.js`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
## New entity focus ## New entity focus
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation. When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BUTTON, Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH, Platform.SWITCH,
Platform.SENSOR, Platform.SENSOR,
Platform.NUMBER, Platform.NUMBER,
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_on_coordinator_update) coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if coord:
await coord.push_segments(source_id, segments)
break
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch scene presets: %s", err) _LOGGER.warning("Failed to fetch scene presets: %s", err)
return [] return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None: async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset.""" """Activate a scene preset."""
async with self.session.post( async with self.session.post(
@@ -352,6 +384,21 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
resp.raise_for_status() resp.raise_for_status()
await self.async_request_refresh() await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id, resp.status, body,
)
async def update_target(self, target_id: str, **kwargs: Any) -> None: async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update a output target's fields.""" """Update a output target's fields."""
async with self.session.put( async with self.session.put(

View File

@@ -0,0 +1,147 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Local state — not derived from coordinator data
self._is_on: bool = False
self._rgb_color: tuple[int, int, int] = (255, 255, 255)
self._brightness: int = 255
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
# Update fallback_color so the color persists beyond the timeout
await self.coordinator.update_source(
self._source_id, fallback_color=scaled,
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing black and setting fallback to black."""
off_color = [0, 0, 0]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
)
await self.coordinator.update_source(
self._source_id, fallback_color=off_color,
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]

View File

@@ -0,0 +1,19 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Processing" "name": "Processing"
@@ -66,5 +71,21 @@
"name": "Brightness Source" "name": "Brightness Source"
} }
} }
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
} }
} }

View File

@@ -89,7 +89,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
webhook_url = None webhook_url = None
for c in automation.conditions: for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token: if isinstance(c, WebhookCondition) and c.token:
if request: # Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}" webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
else: else:
webhook_url = f"/api/v1/webhooks/{c.token}" webhook_url = f"/api/v1/webhooks/{c.token}"

View File

@@ -410,7 +410,8 @@ async def push_colors(
): ):
"""Push raw LED colors to an api_input color strip source. """Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source. Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
The payload is forwarded to all running stream instances for this source.
""" """
try: try:
source = store.get_source(source_id) source = store.get_source(source_id)
@@ -420,15 +421,27 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource): if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type") raise HTTPException(status_code=400, detail="Source is not an api_input type")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
if body.segments is not None:
# Segment-based path
seg_dicts = [s.model_dump() for s in body.segments]
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
return {
"status": "ok",
"streams_updated": len(streams),
"segments_applied": len(body.segments),
}
else:
# Legacy flat colors path
colors_array = np.array(body.colors, dtype=np.uint8) colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets") raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
stream.push_colors(colors_array) stream.push_colors(colors_array)
return { return {
"status": "ok", "status": "ok",
"streams_updated": len(streams), "streams_updated": len(streams),
@@ -708,18 +721,41 @@ async def css_api_input_ws(
break break
if "text" in message: if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]} # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json import json
try: try:
data = json.loads(message["text"]) data = json.loads(message["text"])
raw_colors = data.get("colors", []) except (json.JSONDecodeError, ValueError) as e:
await websocket.send_json({"error": str(e)})
continue
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
await websocket.send_json({"error": f"Invalid segment: {e}"})
continue
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
continue
elif "colors" in data:
try:
raw_colors = data["colors"]
colors_array = np.array(raw_colors, dtype=np.uint8) colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"}) await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue continue
except (json.JSONDecodeError, ValueError, TypeError) as e: except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)}) await websocket.send_json({"error": str(e)})
continue continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message: elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED) # Binary frame: raw RGBRGB... bytes (3 bytes per LED)
@@ -732,7 +768,7 @@ async def css_api_input_ws(
else: else:
continue continue
# Push to all running streams # Push to all running streams (colors_array path only reaches here)
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
try: try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
# Send metadata as first message # Send metadata as first message
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource) is_composite = isinstance(source, CompositeColorStripSource)
@@ -874,6 +914,15 @@ async def test_color_strip_ws(
await websocket.send_bytes(b''.join(parts)) await websocket.send_bytes(b''.join(parts))
elif composite_colors is not None: elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes()) await websocket.send_bytes(composite_colors.tobytes())
else:
# For api_input: only send when new data was pushed
if is_api_input:
gen = stream.push_generation
if gen != _last_push_gen:
_last_push_gen = gen
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else: else:
colors = stream.get_latest_colors() colors = stream.get_latest_colors()
if colors is not None: if colors is not None:

View File

@@ -43,6 +43,8 @@ from wled_controller.api.schemas.system import (
BackupListResponse, BackupListResponse,
DisplayInfo, DisplayInfo,
DisplayListResponse, DisplayListResponse,
ExternalUrlRequest,
ExternalUrlResponse,
GpuInfo, GpuInfo,
HealthResponse, HealthResponse,
LogLevelRequest, LogLevelRequest,
@@ -763,6 +765,63 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
) )
# ---------------------------------------------------------------------------
# External URL setting
# ---------------------------------------------------------------------------
_EXTERNAL_URL_FILE: Path | None = None
def _get_external_url_path() -> Path:
global _EXTERNAL_URL_FILE
if _EXTERNAL_URL_FILE is None:
cfg = get_config()
data_dir = Path(cfg.storage.devices_file).parent
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
return _EXTERNAL_URL_FILE
def load_external_url() -> str:
"""Load the external URL setting. Returns empty string if not set."""
path = _get_external_url_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("external_url", "")
except Exception:
pass
return ""
def _save_external_url(url: str) -> None:
from wled_controller.utils import atomic_write_json
atomic_write_json(_get_external_url_path(), {"external_url": url})
@router.get(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def get_external_url(_: AuthRequired):
"""Get the configured external base URL."""
return ExternalUrlResponse(external_url=load_external_url())
@router.put(
"/api/v1/system/external-url",
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
_save_external_url(url)
logger.info("External URL updated: %s", url or "(cleared)")
return ExternalUrlResponse(external_url=url)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Live log viewer WebSocket # Live log viewer WebSocket
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration from wled_controller.api.schemas.devices import Calibration
@@ -31,7 +31,7 @@ class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source.""" """A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source") source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen") blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active") enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources") count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel): class SegmentPayload(BaseModel):
"""Request to push raw LED colors to an api_input source.""" """A single segment for segment-based LED color updates."""
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)") start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
if self.mode == "solid":
if self.color is None or len(self.color) != 3:
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
if not all(0 <= c <= 255 for c in self.color):
raise ValueError("solid color values must be 0-255")
elif self.mode == "per_pixel":
if not self.colors:
raise ValueError("per_pixel mode requires non-empty 'colors' list")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color in per_pixel must be [R,G,B]")
elif self.mode == "gradient":
if not self.colors or len(self.colors) < 2:
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color stop in gradient must be [R,G,B]")
return self
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source.
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
if self.colors is None and self.segments is None:
raise ValueError("Either 'colors' or 'segments' must be provided")
return self
class NotifyRequest(BaseModel): class NotifyRequest(BaseModel):

View File

@@ -143,6 +143,20 @@ class MQTTSettingsRequest(BaseModel):
base_topic: str = Field(default="ledgrab", description="Base topic prefix") base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ───────────────────────────────────────
class ExternalUrlResponse(BaseModel):
"""External URL setting response."""
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
class ExternalUrlRequest(BaseModel):
"""External URL setting update request."""
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Log level schemas ───────────────────────────────────────── # ─── Log level schemas ─────────────────────────────────────────
class LogLevelResponse(BaseModel): class LogLevelResponse(BaseModel):

View File

@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`. received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler, Thread-safe: push_colors() / push_segments() can be called from any thread
WebSocket handler) while get_latest_colors() is called from the target (REST handler, WebSocket handler) while get_latest_colors() is called from
processor thread. the target processor thread.
""" """
import threading import threading
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_DEFAULT_LED_COUNT = 150
class ApiInputColorStripStream(ColorStripStream): class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data. """Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to push_colors() or push_segments(). A background thread checks for timeout
fallback_color when no data arrives within the configured timeout window. and reverts to fallback_color when no data arrives within the configured
timeout window.
""" """
def __init__(self, source): def __init__(self, source):
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._auto_size = not source.led_count self._led_count = _DEFAULT_LED_COUNT
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
# Build initial fallback buffer # Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0 self._last_push_time: float = 0.0
self._timed_out = True # Start in timed-out state self._timed_out = True # Start in timed-out state
self._push_generation: int = 0 # Incremented on each push; used by test WS
def _build_fallback(self, led_count: int) -> np.ndarray: def _build_fallback(self, led_count: int) -> np.ndarray:
"""Build a (led_count, 3) uint8 array filled with fallback_color.""" """Build a (led_count, 3) uint8 array filled with fallback_color."""
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
(led_count, 1), (led_count, 1),
) )
def _ensure_capacity(self, required: int) -> None:
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
if required > self._led_count:
self._led_count = required
self._fallback_array = self._build_fallback(self._led_count)
# Preserve existing data if not timed out
if not self._timed_out:
new_buf = self._fallback_array.copy()
old_len = min(len(self._colors), required)
new_buf[:old_len] = self._colors[:old_len]
self._colors = new_buf
else:
self._colors = self._fallback_array.copy()
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
def push_colors(self, colors: np.ndarray) -> None: def push_colors(self, colors: np.ndarray) -> None:
"""Push a new frame of LED colors. """Push a new frame of LED colors.
Thread-safe. The array is truncated or zero-padded to match led_count. Thread-safe. Auto-grows the buffer if the incoming array is larger
than the current buffer; otherwise truncates or zero-pads.
Args: Args:
colors: np.ndarray shape (N, 3) uint8 colors: np.ndarray shape (N, 3) uint8
""" """
with self._lock: with self._lock:
n = len(colors) n = len(colors)
# Auto-grow if incoming data is larger
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count: if n == self._led_count:
self._colors = colors.astype(np.uint8) self._colors = colors.astype(np.uint8)
elif n > self._led_count: elif n < self._led_count:
self._colors = colors[:self._led_count].astype(np.uint8)
else:
# Zero-pad to led_count # Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8) padded = np.zeros((self._led_count, 3), dtype=np.uint8)
padded[:n] = colors[:n] padded[:n] = colors[:n]
self._colors = padded self._colors = padded
self._last_push_time = time.monotonic() self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False
def push_segments(self, segments: list) -> None:
"""Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed.
Args:
segments: list of dicts with keys:
start (int) starting LED index
length (int) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
"""
# Compute required buffer size from all segments
max_index = max(seg["start"] + seg["length"] for seg in segments)
with self._lock:
# Auto-grow buffer if needed
if max_index > self._led_count:
self._ensure_capacity(max_index)
# Start from current buffer (or fallback if timed out)
if self._timed_out:
buf = self._fallback_array.copy()
else:
buf = self._colors.copy()
for seg in segments:
start = seg["start"]
length = seg["length"]
mode = seg["mode"]
end = start + length
if mode == "solid":
color = np.array(seg["color"], dtype=np.uint8)
buf[start:end] = color
elif mode == "per_pixel":
colors = np.array(seg["colors"], dtype=np.uint8)
available = len(colors)
if available >= length:
buf[start:end] = colors[:length]
else:
# Pad with zeros if fewer colors than length
buf[start:start + available] = colors
buf[start + available:end] = 0
elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32)
num_stops = len(stops)
# Positions of stops evenly spaced 0..length-1
stop_positions = np.linspace(0, length - 1, num_stops)
pixel_positions = np.arange(length, dtype=np.float32)
for ch in range(3):
buf[start:end, ch] = np.interp(
pixel_positions,
stop_positions,
stops[:, ch],
).astype(np.uint8)
self._colors = buf
self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False self._timed_out = False
def configure(self, device_led_count: int) -> None: def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start). """Set LED count from the target device (called on target start).
Only takes effect when led_count was 0 (auto-size). Always resizes the buffer to the device LED count.
""" """
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: if device_led_count > 0 and device_led_count != self._led_count:
with self._lock: with self._lock:
self._led_count = device_led_count self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count) self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._timed_out = True self._timed_out = True
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs") logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
with self._lock: with self._lock:
return self._colors return self._colors
@property
def push_generation(self) -> int:
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
return self._push_generation
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update fallback_color and timeout from updated source config.""" """Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource from wled_controller.storage.color_strip_source import ApiInputColorStripSource
@@ -138,15 +230,6 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
prev_led_count = self._led_count if self._auto_size else None
self._auto_size = not source.led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
# Preserve runtime LED count across updates if auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._lock: with self._lock:
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out: if self._timed_out:

View File

@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
_BLEND_ADD = "add" _BLEND_ADD = "add"
_BLEND_MULTIPLY = "multiply" _BLEND_MULTIPLY = "multiply"
_BLEND_SCREEN = "screen" _BLEND_SCREEN = "screen"
_BLEND_OVERRIDE = "override"
class CompositeColorStripStream(ColorStripStream): class CompositeColorStripStream(ColorStripStream):
@@ -300,11 +301,34 @@ class CompositeColorStripStream(ColorStripStream):
u16a >>= 8 u16a >>= 8
np.copyto(out, u16a, casting="unsafe") np.copyto(out, u16a, casting="unsafe")
def _blend_override(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
out: np.ndarray) -> None:
"""Override blend: per-pixel alpha derived from top brightness.
Black pixels are fully transparent (bottom shows through),
bright pixels fully opaque (top replaces bottom). Layer opacity
scales the per-pixel alpha.
"""
u16a, u16b = self._u16_a, self._u16_b
# Per-pixel brightness = max(R, G, B) for each LED
per_px_alpha = np.max(top, axis=1, keepdims=True).astype(np.uint16)
# Scale by layer opacity
per_px_alpha = (per_px_alpha * alpha) >> 8
# Lerp: out = (bottom * (256 - per_px_alpha) + top * per_px_alpha) >> 8
np.copyto(u16a, bottom, casting="unsafe")
np.copyto(u16b, top, casting="unsafe")
u16a *= (256 - per_px_alpha)
u16b *= per_px_alpha
u16a += u16b
u16a >>= 8
np.copyto(out, u16a, casting="unsafe")
_BLEND_DISPATCH = { _BLEND_DISPATCH = {
_BLEND_NORMAL: "_blend_normal", _BLEND_NORMAL: "_blend_normal",
_BLEND_ADD: "_blend_add", _BLEND_ADD: "_blend_add",
_BLEND_MULTIPLY: "_blend_multiply", _BLEND_MULTIPLY: "_blend_multiply",
_BLEND_SCREEN: "_blend_screen", _BLEND_SCREEN: "_blend_screen",
_BLEND_OVERRIDE: "_blend_override",
} }
# ── Processing loop ───────────────────────────────────────── # ── Processing loop ─────────────────────────────────────────

View File

@@ -1172,3 +1172,62 @@ html:has(#tab-graph.active) {
background: var(--border-color); background: var(--border-color);
margin: 4px 0; margin: 4px 0;
} }
/* ── Node hover FPS tooltip ── */
.graph-node-tooltip {
position: absolute;
z-index: 50;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 14px var(--shadow-color, rgba(0,0,0,0.25));
padding: 8px 12px;
pointer-events: none;
font-size: 0.8rem;
width: 200px;
color: var(--text-color);
}
.graph-node-tooltip .gnt-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
line-height: 1.6;
}
.graph-node-tooltip .gnt-label {
color: var(--text-muted);
white-space: nowrap;
}
.graph-node-tooltip .gnt-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
text-align: right;
min-width: 72px;
display: inline-block;
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
}
.graph-node-tooltip .gnt-fps-row {
margin-top: 4px;
padding: 2px 0;
background: transparent;
}
.graph-node-tooltip.gnt-fade-in {
animation: gntFadeIn 0.15s ease-out forwards;
}
.graph-node-tooltip.gnt-fade-out {
animation: gntFadeOut 0.12s ease-in forwards;
}
@keyframes gntFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gntFadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}

View File

@@ -269,6 +269,8 @@
font-size: 0.9em; font-size: 0.9em;
} }
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
/* Composite layers preview */ /* Composite layers preview */
.css-test-layers { .css-test-layers {
display: flex; display: flex;
@@ -346,7 +348,107 @@
opacity: 1; opacity: 1;
} }
/* ── Log viewer ─────────────────────────────────────────────── */ /* ── Settings modal tabs ───────────────────────────────────── */
.settings-tab-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color);
padding: 0 1.25rem;
}
.settings-tab-btn {
background: none;
border: none;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s ease, border-color 0.25s ease;
}
.settings-tab-btn:hover {
color: var(--text-color);
}
.settings-tab-btn.active {
color: var(--primary-text-color);
border-bottom-color: var(--primary-color);
}
.settings-panel {
display: none;
}
.settings-panel.active {
display: block;
animation: tabFadeIn 0.25s ease-out;
}
/* ── Log viewer overlay (full-screen) ──────────────────────── */
.log-overlay {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
flex-direction: column;
background: var(--bg-color, #111);
padding: 12px 16px;
animation: fadeIn 0.2s ease-out;
}
.log-overlay-close {
position: absolute;
top: 8px;
right: 12px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.3rem;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
z-index: 1;
transition: color 0.15s, background 0.15s;
}
.log-overlay-close:hover {
color: var(--text-color);
background: var(--border-color);
}
.log-overlay-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 10px;
padding-right: 36px; /* space for corner close btn */
flex-shrink: 0;
}
.log-overlay-toolbar h3 {
margin: 0;
font-size: 1rem;
white-space: nowrap;
margin-right: 4px;
}
.log-overlay .log-viewer-output {
flex: 1;
max-height: none;
border-radius: 8px;
min-height: 0;
}
/* ── Log viewer base ───────────────────────────────────────── */
.log-viewer-output { .log-viewer-output {
background: #0d0d0d; background: #0d0d0d;
@@ -1414,6 +1516,19 @@
min-width: 0; min-width: 0;
} }
.composite-layer-brightness-label {
flex-shrink: 0;
width: 90px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.composite-layer-brightness,
.composite-layer-cspt {
flex: 1;
min-width: 0;
}
.composite-layer-blend { .composite-layer-blend {
width: 100px; width: 100px;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -99,6 +99,17 @@
text-align: center; text-align: center;
} }
/* ── Nested sub-group (group inside a group) ── */
.tree-group-nested > .tree-group-header {
font-size: 0.75rem;
text-transform: none;
letter-spacing: normal;
font-weight: 600;
margin-top: 2px;
padding: 4px 10px 4px 12px;
}
/* ── Children (leaves) ── */ /* ── Children (leaves) ── */
.tree-children { .tree-children {

View File

@@ -182,13 +182,16 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
import { navigateToCard } from './core/navigation.js'; import { navigateToCard } from './core/navigation.js';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
import { import {
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected,
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
restartServer, saveMqttSettings, restartServer, saveMqttSettings,
loadApiKeysList, loadApiKeysList,
downloadPartialExport, handlePartialImportFileSelected, downloadPartialExport, handlePartialImportFileSelected,
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
saveExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.js'; } from './features/settings.js';
// ─── Register all HTML onclick / onchange / onfocus globals ─── // ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -522,9 +525,10 @@ Object.assign(window, {
openCommandPalette, openCommandPalette,
closeCommandPalette, closeCommandPalette,
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level) // settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
openSettingsModal, openSettingsModal,
closeSettingsModal, closeSettingsModal,
switchSettingsTab,
downloadBackup, downloadBackup,
handleRestoreFileSelected, handleRestoreFileSelected,
saveAutoBackupSettings, saveAutoBackupSettings,
@@ -540,8 +544,12 @@ Object.assign(window, {
disconnectLogViewer, disconnectLogViewer,
clearLogViewer, clearLogViewer,
applyLogFilter, applyLogFilter,
openLogOverlay,
closeLogOverlay,
loadLogLevel, loadLogLevel,
setLogLevel, setLogLevel,
saveExternalUrl,
getBaseOrigin,
}); });
// ─── Global keyboard shortcuts ─── // ─── Global keyboard shortcuts ───
@@ -569,8 +577,11 @@ document.addEventListener('keydown', (e) => {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals via stack // Close in order: log overlay > overlay lightboxes > modals via stack
if (document.getElementById('display-picker-lightbox').classList.contains('active')) { const logOverlay = document.getElementById('log-overlay');
if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker(); closeDisplayPicker();
} else if (document.getElementById('image-lightbox').classList.contains('active')) { } else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox(); closeLightbox();
@@ -605,6 +616,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale (dispatches languageChanged which may trigger API calls) // Initialize locale (dispatches languageChanged which may trigger API calls)
await initLocale(); await initLocale();
// Load external URL setting early so getBaseOrigin() is available for card rendering
loadExternalUrl();
// Restore active tab before showing content to avoid visible jump // Restore active tab before showing content to avoid visible jump
initTabs(); initTabs();

View File

@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
pattern_template: patternTemplatesCache, pattern_template: patternTemplatesCache,
}; };
/** Maps entity_type to the window load function that refreshes its UI. */
const ENTITY_LOADER_MAP = {
device: 'loadTargetsTab',
output_target: 'loadTargetsTab',
color_strip_source: 'loadTargetsTab',
pattern_template: 'loadTargetsTab',
picture_source: 'loadPictureSources',
audio_source: 'loadPictureSources',
value_source: 'loadPictureSources',
sync_clock: 'loadPictureSources',
capture_template: 'loadPictureSources',
audio_template: 'loadPictureSources',
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
};
/** Debounce timers per loader function name — coalesces rapid WS events and
* avoids a redundant re-render when the local save handler already triggered one. */
const _loaderTimers = {};
const _LOADER_DEBOUNCE_MS = 600;
function _invalidateAndReload(entityType) { function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType]; const cache = ENTITY_CACHE_MAP[entityType];
if (cache) { if (!cache) return;
cache.fetch({ force: true });
const oldData = cache.data;
cache.fetch({ force: true }).then((newData) => {
// Skip UI refresh if the data didn't actually change —
// the local save handler already refreshed the UI.
if (oldData === newData) return;
if (Array.isArray(oldData) && Array.isArray(newData) &&
oldData.length === newData.length &&
JSON.stringify(oldData) === JSON.stringify(newData)) return;
const loader = ENTITY_LOADER_MAP[entityType];
if (loader) {
clearTimeout(_loaderTimers[loader]);
_loaderTimers[loader] = setTimeout(() => {
delete _loaderTimers[loader];
if (typeof window[loader] === 'function') window[loader]();
}, _LOADER_DEBOUNCE_MS);
} }
});
document.dispatchEvent(new CustomEvent('entity:reload', { document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType }, detail: { entity_type: entityType },
})); }));

View File

@@ -292,7 +292,7 @@ function renderNode(node, callbacks) {
class: 'graph-node-title', class: 'graph-node-title',
x: 16, y: 24, x: 16, y: 24,
}); });
title.textContent = truncate(name, 18); title.textContent = name;
g.appendChild(title); g.appendChild(title);
// Subtitle (type) // Subtitle (type)
@@ -305,11 +305,6 @@ function renderNode(node, callbacks) {
g.appendChild(sub); g.appendChild(sub);
} }
// Tooltip
const tip = svgEl('title');
tip.textContent = `${name} (${kind.replace(/_/g, ' ')})`;
g.appendChild(tip);
// Hover overlay (action buttons) // Hover overlay (action buttons)
const overlay = _createOverlay(node, width, callbacks); const overlay = _createOverlay(node, width, callbacks);
g.appendChild(overlay); g.appendChild(overlay);

View File

@@ -2,9 +2,12 @@
* TreeNav — hierarchical sidebar navigation for Targets and Sources tabs. * TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
* Replaces flat sub-tab bars with a collapsible tree that groups related items. * Replaces flat sub-tab bars with a collapsible tree that groups related items.
* *
* Config format: * Config format (supports arbitrary nesting):
* [ * [
* { key, titleKey, icon?, children: [{ key, titleKey, icon?, count, subTab?, sectionKey? }] }, * { key, titleKey, icon?, children: [
* { key, titleKey, icon?, children: [...] }, // nested group
* { key, titleKey, icon?, count } // leaf
* ] },
* { key, titleKey, icon?, count } // standalone leaf (no children) * { key, titleKey, icon?, count } // standalone leaf (no children)
* ] * ]
*/ */
@@ -25,6 +28,12 @@ function _saveCollapsed(key, collapsed) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} }
/** Recursively sum leaf counts in a tree node. */
function _deepCount(node) {
if (!node.children) return node.count || 0;
return node.children.reduce((sum, c) => sum + _deepCount(c), 0);
}
export class TreeNav { export class TreeNav {
/** /**
* @param {string} containerId - ID of the nav element to render into * @param {string} containerId - ID of the nav element to render into
@@ -71,15 +80,22 @@ export class TreeNav {
const leaf = this._leafMap.get(key); const leaf = this._leafMap.get(key);
if (leaf) leaf.count = count; if (leaf) leaf.count = count;
} }
// Update group counts // Update group counts (bottom-up: deepest first)
container.querySelectorAll('[data-tree-group]').forEach(groupEl => { const groups = [...container.querySelectorAll('[data-tree-group]')];
groups.reverse();
for (const groupEl of groups) {
let total = 0; let total = 0;
groupEl.querySelectorAll('.tree-leaf .tree-count').forEach(cnt => { // Sum direct leaf children
for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-leaf .tree-count')) {
total += parseInt(cnt.textContent, 10) || 0; total += parseInt(cnt.textContent, 10) || 0;
}); }
const groupCount = groupEl.querySelector('.tree-group-count'); // Sum nested sub-group counts
for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-group > .tree-group-header > .tree-group-count')) {
total += parseInt(cnt.textContent, 10) || 0;
}
const groupCount = groupEl.querySelector(':scope > .tree-group-header > .tree-group-count');
if (groupCount) groupCount.textContent = total; if (groupCount) groupCount.textContent = total;
}); }
} }
/** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */ /** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
@@ -116,11 +132,13 @@ export class TreeNav {
_buildLeafMap() { _buildLeafMap() {
this._leafMap.clear(); this._leafMap.clear();
for (const item of this._items) { this._collectLeaves(this._items);
if (item.children) {
for (const child of item.children) {
this._leafMap.set(child.key, child);
} }
_collectLeaves(items) {
for (const item of items) {
if (item.children) {
this._collectLeaves(item.children);
} else { } else {
this._leafMap.set(item.key, item); this._leafMap.set(item.key, item);
} }
@@ -135,7 +153,7 @@ export class TreeNav {
const html = this._items.map(item => { const html = this._items.map(item => {
if (item.children) { if (item.children) {
return this._renderGroup(item, collapsed); return this._renderGroup(item, collapsed, 0);
} }
return this._renderStandalone(item); return this._renderStandalone(item);
}).join(''); }).join('');
@@ -145,12 +163,24 @@ export class TreeNav {
this._bindEvents(container); this._bindEvents(container);
} }
_renderGroup(group, collapsed) { _renderGroup(group, collapsed, depth) {
const isCollapsed = !!collapsed[group.key]; const isCollapsed = !!collapsed[group.key];
const groupCount = group.children.reduce((sum, c) => sum + (c.count || 0), 0); const groupCount = _deepCount(group);
const childrenHtml = group.children.map(child => {
if (child.children) {
return this._renderGroup(child, collapsed, depth + 1);
}
return `
<div class="tree-leaf${child.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${child.key}">
${child.icon ? `<span class="tree-node-icon">${child.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${child.titleKey}">${t(child.titleKey)}</span>
<span class="tree-count">${child.count ?? 0}</span>
</div>`;
}).join('');
return ` return `
<div class="tree-group" data-tree-group="${group.key}"> <div class="tree-group${depth > 0 ? ' tree-group-nested' : ''}" data-tree-group="${group.key}">
<div class="tree-group-header" data-tree-group-toggle="${group.key}"> <div class="tree-group-header" data-tree-group-toggle="${group.key}">
<span class="tree-chevron${isCollapsed ? '' : ' open'}">&#9654;</span> <span class="tree-chevron${isCollapsed ? '' : ' open'}">&#9654;</span>
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''} ${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
@@ -158,13 +188,7 @@ export class TreeNav {
<span class="tree-group-count">${groupCount}</span> <span class="tree-group-count">${groupCount}</span>
</div> </div>
<div class="tree-children${isCollapsed ? ' collapsed' : ''}"> <div class="tree-children${isCollapsed ? ' collapsed' : ''}">
${group.children.map(leaf => ` ${childrenHtml}
<div class="tree-leaf${leaf.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${leaf.key}">
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
<span class="tree-count">${leaf.count ?? 0}</span>
</div>
`).join('')}
</div> </div>
</div>`; </div>`;
} }

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations. * This module manages the editor modal and API operations.
*/ */
import { _cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js'; import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
@@ -155,6 +155,7 @@ export async function saveAudioSource() {
} }
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose(); audioSourceModal.forceClose();
audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
errorEl.textContent = e.message; errorEl.textContent = e.message;
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('audio_source.deleted'), 'success'); showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');

View File

@@ -13,6 +13,7 @@ import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICO
import * as P from '../core/icon-paths.js'; import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
import { getBaseOrigin } from './settings.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
import { attachProcessPicker } from '../core/process-picker.js'; import { attachProcessPicker } from '../core/process-picker.js';
@@ -546,7 +547,7 @@ function addAutomationConditionRow(condition) {
} }
if (type === 'webhook') { if (type === 'webhook') {
if (data.token) { if (data.token) {
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token; const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small> <small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
@@ -706,6 +707,7 @@ export async function saveAutomationEditor() {
automationModal.forceClose(); automationModal.forceClose();
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success'); showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -720,6 +722,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
method: 'POST', method: 'POST',
}); });
if (!resp.ok) throw new Error(`Failed to ${action} automation`); if (!resp.ok) throw new Error(`Failed to ${action} automation`);
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -767,6 +770,7 @@ export async function deleteAutomation(automationId, automationName) {
}); });
if (!resp.ok) throw new Error('Failed to delete automation'); if (!resp.ok) throw new Error('Failed to delete automation');
showToast(t('automations.deleted'), 'success'); showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;

View File

@@ -3,6 +3,7 @@
*/ */
import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js'; import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
@@ -21,6 +22,7 @@ import { TagInput, renderTagChips } from '../core/tag-input.js';
import { attachProcessPicker } from '../core/process-picker.js'; import { attachProcessPicker } from '../core/process-picker.js';
import { IconSelect, showTypePicker } from '../core/icon-select.js'; import { IconSelect, showTypePicker } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
import { getBaseOrigin } from './settings.js';
import { import {
rgbArrayToHex, hexToRgbArray, rgbArrayToHex, hexToRgbArray,
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset, gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
@@ -198,8 +200,8 @@ export function onCSSTypeChange() {
} }
_syncAnimationSpeedState(); _syncAnimationSpeedState();
// LED count — only shown for picture, picture_advanced, api_input // LED count — only shown for picture, picture_advanced
const hasLedCount = ['picture', 'picture_advanced', 'api_input']; const hasLedCount = ['picture', 'picture_advanced'];
document.getElementById('css-editor-led-count-group').style.display = document.getElementById('css-editor-led-count-group').style.display =
hasLedCount.includes(type) ? '' : 'none'; hasLedCount.includes(type) ? '' : 'none';
@@ -684,6 +686,7 @@ function _getCompositeBlendItems() {
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') }, { value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') }, { value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') }, { value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
{ value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') },
]; ];
} }
@@ -740,6 +743,7 @@ function _compositeRenderList() {
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option> <option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option> <option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option> <option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
</select> </select>
</div> </div>
<div class="composite-layer-row"> <div class="composite-layer-row">
@@ -1446,7 +1450,7 @@ function _showNotificationEndpoint(cssId) {
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`; el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
return; return;
} }
const base = `${window.location.origin}/api/v1`; const base = `${getBaseOrigin()}/api/v1`;
const url = `${base}/color-strip-sources/${cssId}/notify`; const url = `${base}/color-strip-sources/${cssId}/notify`;
el.innerHTML = ` el.innerHTML = `
<small class="endpoint-label">POST</small> <small class="endpoint-label">POST</small>
@@ -1656,9 +1660,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const notifHistoryBtn = isNotification const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>` ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: ''; : '';
const testPreviewBtn = !isApiInput const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
: '';
return wrapCard({ return wrapCard({
dataAttr: 'data-css-id', dataAttr: 'data-css-id',
@@ -2259,6 +2261,7 @@ export async function saveCSSEditor() {
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success'); showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
colorStripSourcesCache.invalidate(); colorStripSourcesCache.invalidate();
cssEditorModal.forceClose(); cssEditorModal.forceClose();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -2277,9 +2280,11 @@ function _showApiInputEndpoints(cssId) {
el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`; el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
return; return;
} }
const base = `${window.location.origin}/api/v1`; const origin = getBaseOrigin();
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const base = `${origin}/api/v1`;
const wsBase = `${wsProto}//${window.location.host}/api/v1`; const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:';
const hostPart = origin.replace(/^https?:\/\//, '');
const wsBase = `${wsProto}//${hostPart}/api/v1`;
const restUrl = `${base}/color-strip-sources/${cssId}/colors`; const restUrl = `${base}/color-strip-sources/${cssId}/colors`;
const apiKey = localStorage.getItem('wled_api_key') || ''; const apiKey = localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`; const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
@@ -2335,6 +2340,7 @@ export async function deleteColorStrip(cssId) {
if (response.ok) { if (response.ok) {
showToast(t('color_strip.deleted'), 'success'); showToast(t('color_strip.deleted'), 'success');
colorStripSourcesCache.invalidate(); colorStripSourcesCache.invalidate();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} else { } else {
const err = await response.json(); const err = await response.json();
@@ -2476,6 +2482,11 @@ let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messa
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers) let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode = false; // true when testing a CSPT template let _cssTestCSPTMode = false; // true when testing a CSPT template
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput = false;
let _cssTestFpsTimestamps = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory = []; // rolling FPS samples for sparkline
let _cssTestFpsChart = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect = null; let _csptTestInputEntitySelect = null;
function _getCssTestLedCount() { function _getCssTestLedCount() {
@@ -2488,12 +2499,32 @@ function _getCssTestFps() {
return (stored >= 1 && stored <= 60) ? stored : 20; return (stored >= 1 && stored <= 60) ? stored : 20;
} }
function _populateCssTestSourceSelector(preselectId) {
const sources = colorStripSourcesCache.data || [];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select');
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
export function testColorStrip(sourceId) { export function testColorStrip(sourceId) {
_cssTestCSPTMode = false; _cssTestCSPTMode = false;
_cssTestCSPTId = null; _cssTestCSPTId = null;
// Hide CSPT input selector // Detect api_input type
const csptGroup = document.getElementById('css-test-cspt-input-group'); const sources = colorStripSourcesCache.data || [];
if (csptGroup) csptGroup.style.display = 'none'; const src = sources.find(s => s.id === sourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId); _openTestModal(sourceId);
} }
@@ -2503,25 +2534,9 @@ export async function testCSPT(templateId) {
// Populate input source selector // Populate input source selector
await colorStripSourcesCache.fetch(); await colorStripSourcesCache.fetch();
const sources = colorStripSourcesCache.data || []; _populateCssTestSourceSelector(null);
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select');
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// EntitySelect for input source picker
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
// Show CSPT input selector
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = '';
const sel = document.getElementById('css-test-cspt-input-select');
const inputId = sel.value; const inputId = sel.value;
if (!inputId) { if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error'); showToast(t('color_strip.processed.error.no_input'), 'error');
@@ -2550,11 +2565,29 @@ function _openTestModal(sourceId) {
document.getElementById('css-test-rect-view').style.display = 'none'; document.getElementById('css-test-rect-view').style.display = 'none';
document.getElementById('css-test-layers-view').style.display = 'none'; document.getElementById('css-test-layers-view').style.display = 'none';
document.getElementById('css-test-led-group').style.display = ''; document.getElementById('css-test-led-group').style.display = '';
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
const layersContainer = document.getElementById('css-test-layers'); const layersContainer = document.getElementById('css-test-layers');
if (layersContainer) layersContainer.innerHTML = ''; if (layersContainer) layersContainer.innerHTML = '';
document.getElementById('css-test-status').style.display = ''; document.getElementById('css-test-status').style.display = '';
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting'); document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
// Reset FPS tracking
_cssTestFpsHistory = [];
// For api_input: hide LED/FPS controls, show FPS chart
const ledControlGroup = document.getElementById('css-test-led-fps-group');
const fpsChartGroup = document.getElementById('css-test-fps-chart-group');
if (_cssTestIsApiInput) {
if (ledControlGroup) ledControlGroup.style.display = 'none';
if (fpsChartGroup) fpsChartGroup.style.display = '';
_cssTestStartFpsSampling();
// Use large LED count (buffer auto-sizes) and high poll FPS
_cssTestConnect(sourceId, 1000, 60);
} else {
if (ledControlGroup) ledControlGroup.style.display = '';
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
// Restore LED count + FPS + Enter key handlers // Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount(); const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input'); const ledInput = document.getElementById('css-test-led-input');
@@ -2568,6 +2601,7 @@ function _openTestModal(sourceId) {
_cssTestConnect(sourceId, ledCount, fpsVal); _cssTestConnect(sourceId, ledCount, fpsVal);
} }
}
function _cssTestConnect(sourceId, ledCount, fps) { function _cssTestConnect(sourceId, ledCount, fps) {
// Close existing connection if any // Close existing connection if any
@@ -2714,6 +2748,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
// Standard format: raw RGB // Standard format: raw RGB
_cssTestLatestRgb = raw; _cssTestLatestRgb = raw;
} }
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
} }
}; };
@@ -2805,12 +2844,14 @@ export function applyCssTestSettings() {
_cssTestMeta = null; _cssTestMeta = null;
_cssTestLayerData = null; _cssTestLayerData = null;
// In CSPT mode, read selected input source // Read selected input source from selector (both CSS and CSPT modes)
if (_cssTestCSPTMode) {
const inputSel = document.getElementById('css-test-cspt-input-select'); const inputSel = document.getElementById('css-test-cspt-input-select');
if (inputSel && inputSel.value) { if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value; _cssTestSourceId = inputSel.value;
} // Re-detect api_input when source changes
const sources = colorStripSourcesCache.data || [];
const src = sources.find(s => s.id === _cssTestSourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
} }
// Reconnect (generation counter ignores stale frames from old WS) // Reconnect (generation counter ignores stale frames from old WS)
@@ -3162,6 +3203,60 @@ export function fireCssTestNotificationLayer(sourceId) {
testNotification(sourceId); testNotification(sourceId);
} }
let _cssTestFpsSampleInterval = null;
function _cssTestStartFpsSampling() {
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
// Sample FPS every 1 second
_cssTestFpsSampleInterval = setInterval(() => {
const now = performance.now();
// Count frames in the last 1 second
const cutoff = now - 1000;
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
const fps = _cssTestFpsTimestamps.length;
_cssTestFpsActualHistory.push(fps);
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
_cssTestFpsActualHistory.shift();
// Update numeric display (match target card format)
const valueEl = document.getElementById('css-test-fps-value');
if (valueEl) valueEl.textContent = fps;
const avgEl = document.getElementById('css-test-fps-avg');
if (avgEl && _cssTestFpsActualHistory.length > 1) {
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
// Create or update chart
if (!_cssTestFpsChart) {
_cssTestFpsChart = createFpsSparkline(
'css-test-fps-chart',
_cssTestFpsActualHistory,
[], // no "current" dataset, just actual
60, // y-axis max
);
}
if (_cssTestFpsChart) {
const ds = _cssTestFpsChart.data.datasets[0].data;
ds.length = 0;
ds.push(..._cssTestFpsActualHistory);
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
_cssTestFpsChart.data.labels.length = ds.length;
_cssTestFpsChart.update('none');
}
}, 1000);
}
function _cssTestStopFpsSampling() {
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}
export function closeTestCssSourceModal() { export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
@@ -3171,6 +3266,10 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false; _cssTestIsComposite = false;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestNotificationIds = []; _cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview // Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen'); const screen = document.getElementById('css-test-rect-screen');
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; } if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }

View File

@@ -16,6 +16,7 @@ import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REF
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
import { getBaseOrigin } from './settings.js';
let _deviceTagsInput = null; let _deviceTagsInput = null;
let _settingsCsptEntitySelect = null; let _settingsCsptEntitySelect = null;
@@ -366,9 +367,11 @@ export async function showSettings(deviceId) {
const wsUrlGroup = document.getElementById('settings-ws-url-group'); const wsUrlGroup = document.getElementById('settings-ws-url-group');
if (wsUrlGroup) { if (wsUrlGroup) {
if (isWs) { if (isWs) {
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const origin = getBaseOrigin();
const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:';
const hostPart = origin.replace(/^https?:\/\//, '');
const apiKey = localStorage.getItem('wled_api_key') || ''; const apiKey = localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsProto}//${location.host}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`;
document.getElementById('settings-ws-url').value = wsUrl; document.getElementById('settings-ws-url').value = wsUrl;
wsUrlGroup.style.display = ''; wsUrlGroup.style.display = '';
} else { } else {

View File

@@ -14,7 +14,8 @@ import {
automationsCacheObj, csptCache, automationsCacheObj, csptCache,
} from '../core/state.js'; } from '../core/state.js';
import { fetchWithAuth } from '../core/api.js'; import { fetchWithAuth } from '../core/api.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
import { showTypePicker } from '../core/icon-select.js'; import { showTypePicker } from '../core/icon-select.js';
@@ -691,6 +692,7 @@ function _renderGraph(container) {
_initToolbarDrag(container.querySelector('.graph-toolbar')); _initToolbarDrag(container.querySelector('.graph-toolbar'));
_initResizeClamp(container); _initResizeClamp(container);
_initNodeDrag(nodeGroup, edgeGroup); _initNodeDrag(nodeGroup, edgeGroup);
_initNodeHoverTooltip(nodeGroup, container);
_initPortDrag(svgEl, nodeGroup, edgeGroup); _initPortDrag(svgEl, nodeGroup, edgeGroup);
_initRubberBand(svgEl); _initRubberBand(svgEl);
@@ -2238,6 +2240,243 @@ async function _detachSelectedEdge() {
} }
} }
/* ── Node hover FPS tooltip ── */
let _hoverTooltip = null; // the <div> element, created once per graph render
let _hoverTooltipChart = null; // Chart.js instance
let _hoverTimer = null; // 300ms delay timer
let _hoverPollInterval = null; // 1s polling interval
let _hoverNodeId = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
function _initNodeHoverTooltip(nodeGroup, container) {
// Create or reset the tooltip element
container.querySelector('.graph-node-tooltip')?.remove();
const tip = document.createElement('div');
tip.className = 'graph-node-tooltip';
tip.style.display = 'none';
tip.innerHTML = `
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.errors')}</span><span class="gnt-value" data-gnt="errors">—</span></div>
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.uptime')}</span><span class="gnt-value" data-gnt="uptime">—</span></div>
<div class="target-fps-row gnt-fps-row">
<div class="target-fps-sparkline"><canvas id="gnt-sparkline-canvas"></canvas></div>
<div class="target-fps-label">
<span class="metric-value" data-gnt="fps">—</span>
<span class="target-fps-avg" data-gnt="fps-avg"></span>
</div>
</div>
`;
container.appendChild(tip);
_hoverTooltip = tip;
_hoverTooltipChart = null;
nodeGroup.addEventListener('pointerover', (e) => {
const nodeEl = e.target.closest('.graph-node.running[data-kind="output_target"]');
if (!nodeEl) return;
const nodeId = nodeEl.getAttribute('data-id');
if (!nodeId) return;
// Already showing for this node — nothing to do
if (_hoverNodeId === nodeId && tip.style.display !== 'none') return;
clearTimeout(_hoverTimer);
_hoverTimer = setTimeout(() => {
_showNodeTooltip(nodeId, nodeEl, container);
}, HOVER_DELAY_MS);
});
nodeGroup.addEventListener('pointerout', (e) => {
const nodeEl = e.target.closest('.graph-node');
if (!nodeEl) return;
// Ignore if pointer moved to another child of the same node
const related = e.relatedTarget;
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
_hideNodeTooltip();
}
}, true);
}
function _positionTooltip(nodeEl, container) {
if (!_canvas || !_hoverTooltip) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
// Convert graph-coordinate node origin to container-relative CSS pixels
const cssX = (node.x - _canvas.viewX) * _canvas.zoom;
const cssY = (node.y - _canvas.viewY) * _canvas.zoom;
const cssW = node.width * _canvas.zoom;
const tipW = _hoverTooltip.offsetWidth || 180;
const tipH = _hoverTooltip.offsetHeight || 120;
const contW = container.offsetWidth;
const contH = container.offsetHeight;
const cssH = node.height * _canvas.zoom;
// Position below the node, centered horizontally
let left = cssX + (cssW - tipW) / 2;
left = Math.max(8, Math.min(left, contW - tipW - 8));
let top = cssY + cssH + 8;
// If no room below, show above
if (top + tipH > contH - 8) {
top = cssY - tipH - 8;
}
top = Math.max(8, top);
_hoverTooltip.style.left = `${left}px`;
_hoverTooltip.style.top = `${top}px`;
}
async function _showNodeTooltip(nodeId, nodeEl, container) {
if (!_hoverTooltip) return;
_hoverNodeId = nodeId;
_hoverFpsHistory = [];
_hoverFpsCurrentHistory = [];
// Destroy previous chart
if (_hoverTooltipChart) {
_hoverTooltipChart.destroy();
_hoverTooltipChart = null;
}
// Seed from server-side metrics history (non-blocking)
try {
const histResp = await fetchWithAuth('/system/metrics-history');
if (_hoverNodeId !== nodeId) return; // user moved away during fetch
if (histResp.ok) {
const hist = await histResp.json();
const samples = hist.targets?.[nodeId] || [];
for (const s of samples) {
if (s.fps != null) _hoverFpsHistory.push(s.fps);
if (s.fps_current != null) _hoverFpsCurrentHistory.push(s.fps_current);
}
// Trim to max length
if (_hoverFpsHistory.length > HOVER_HISTORY_LEN)
_hoverFpsHistory.splice(0, _hoverFpsHistory.length - HOVER_HISTORY_LEN);
if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN)
_hoverFpsCurrentHistory.splice(0, _hoverFpsCurrentHistory.length - HOVER_HISTORY_LEN);
}
} catch (_) { /* ignore — will populate from polls */ }
if (_hoverNodeId !== nodeId) return;
_hoverTooltip.style.display = '';
_hoverTooltip.classList.remove('gnt-fade-out');
_hoverTooltip.classList.add('gnt-fade-in');
_positionTooltip(nodeEl, container);
// Immediate first fetch (also creates the chart with seeded history)
_fetchTooltipMetrics(nodeId, container, nodeEl);
// Poll every 1s
clearInterval(_hoverPollInterval);
_hoverPollInterval = setInterval(() => {
_fetchTooltipMetrics(nodeId, container, nodeEl);
}, 1000);
}
function _hideNodeTooltip() {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverNodeId = null;
if (_hoverTooltipChart) {
_hoverTooltipChart.destroy();
_hoverTooltipChart = null;
}
if (_hoverTooltip) {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
}
}
async function _fetchTooltipMetrics(nodeId, container, nodeEl) {
if (_hoverNodeId !== nodeId) return;
try {
const [metricsResp, stateResp] = await Promise.all([
fetchWithAuth(`/output-targets/${nodeId}/metrics`),
fetchWithAuth(`/output-targets/${nodeId}/state`),
]);
if (_hoverNodeId !== nodeId) return; // node changed while fetching
const metrics = metricsResp.ok ? await metricsResp.json() : {};
const state = stateResp.ok ? await stateResp.json() : {};
const fpsActual = state.fps_actual ?? 0;
const fpsTarget = state.fps_target ?? 30;
const fpsCurrent = state.fps_current ?? 0;
const errorsCount = metrics.errors_count ?? 0;
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');
if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}<span class="target-fps-target">/${fpsTarget}</span>`;
const avgEl = _hoverTooltip.querySelector('[data-gnt="fps-avg"]');
if (avgEl && _hoverFpsHistory.length > 0) {
const avg = _hoverFpsHistory.reduce((a, b) => a + b, 0) / _hoverFpsHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
if (errorsEl) errorsEl.textContent = formatCompact(errorsCount);
if (uptimeEl) uptimeEl.textContent = formatUptime(uptimeSec);
// Push sparkline history
_hoverFpsHistory.push(fpsActual);
_hoverFpsCurrentHistory.push(fpsCurrent);
if (_hoverFpsHistory.length > HOVER_HISTORY_LEN) _hoverFpsHistory.shift();
if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN) _hoverFpsCurrentHistory.shift();
// Update or create chart
if (_hoverTooltipChart) {
_hoverTooltipChart.data.labels = _hoverFpsHistory.map(() => '');
_hoverTooltipChart.data.datasets[0].data = [..._hoverFpsHistory];
_hoverTooltipChart.data.datasets[1].data = [..._hoverFpsCurrentHistory];
_hoverTooltipChart.options.scales.y.max = fpsTarget * 1.15;
_hoverTooltipChart.update('none');
} else {
// Seed history arrays with the first value so chart renders immediately
const seedActual = _hoverFpsHistory.slice();
const seedCurrent = _hoverFpsCurrentHistory.slice();
_hoverTooltipChart = createFpsSparkline(
'gnt-sparkline-canvas',
seedActual,
seedCurrent,
fpsTarget,
);
}
// Re-position in case tooltip changed size
_positionTooltip(nodeEl, container);
} catch (_) {
// Silently ignore fetch errors — tooltip will retry on next interval
}
}
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t()) // Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {
if (_initialized && _nodeMap) { if (_initialized && _nodeMap) {

View File

@@ -243,6 +243,7 @@ export async function saveScenePreset() {
scenePresetModal.forceClose(); scenePresetModal.forceClose();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success'); showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
}); });
if (resp.ok) { if (resp.ok) {
showToast(t('scenes.recaptured'), 'success'); showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } else {
showToast(t('scenes.error.recapture_failed'), 'error'); showToast(t('scenes.error.recapture_failed'), 'error');
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
}); });
if (resp.ok) { if (resp.ok) {
showToast(t('scenes.deleted'), 'success'); showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } else {
showToast(t('scenes.error.delete_failed'), 'error'); showToast(t('scenes.error.delete_failed'), 'error');

View File

@@ -1,5 +1,5 @@
/** /**
* Settings — backup / restore configuration. * Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
*/ */
import { apiKey } from '../core/state.js'; import { apiKey } from '../core/state.js';
@@ -10,6 +10,70 @@ import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
// ─── External URL (used by other modules for user-visible URLs) ──
let _externalUrl = '';
/** Get the configured external base URL (empty string = not set). */
export function getExternalUrl() {
return _externalUrl;
}
/**
* Return the base origin for user-visible URLs (webhook, WS).
* If an external URL is configured, use that; otherwise fall back to window.location.origin.
*/
export function getBaseOrigin() {
return _externalUrl || window.location.origin;
}
export async function loadExternalUrl() {
try {
const resp = await fetchWithAuth('/system/external-url');
if (!resp.ok) return;
const data = await resp.json();
_externalUrl = data.external_url || '';
const input = document.getElementById('settings-external-url');
if (input) input.value = _externalUrl;
} catch (err) {
console.error('Failed to load external URL:', err);
}
}
export async function saveExternalUrl() {
const input = document.getElementById('settings-external-url');
if (!input) return;
const url = input.value.trim().replace(/\/+$/, '');
try {
const resp = await fetchWithAuth('/system/external-url', {
method: 'PUT',
body: JSON.stringify({ external_url: url }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
_externalUrl = data.external_url || '';
input.value = _externalUrl;
showToast(t('settings.external_url.saved'), 'success');
} catch (err) {
console.error('Failed to save external URL:', err);
showToast(t('settings.external_url.save_error') + ': ' + err.message, 'error');
}
}
// ─── Settings-modal tab switching ───────────────────────────
export function switchSettingsTab(tabId) {
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.settingsTab === tabId);
});
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
}
// ─── Log Viewer ──────────────────────────────────────────── // ─── Log Viewer ────────────────────────────────────────────
/** @type {WebSocket|null} */ /** @type {WebSocket|null} */
@@ -114,9 +178,6 @@ export function clearLogViewer() {
/** Re-render the log output according to the current filter selection. */ /** Re-render the log output according to the current filter selection. */
export function applyLogFilter() { 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'); const output = document.getElementById('log-viewer-output');
if (!output) return; if (!output) return;
const filter = _filterLevel(); const filter = _filterLevel();
@@ -128,50 +189,84 @@ export function applyLogFilter() {
} }
} }
// ─── Log Overlay (full-screen) ──────────────────────────────
let _logFilterIconSelect = null;
/** Build filter items lazily so t() has locale data loaded. */
function _getLogFilterItems() {
return [
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
];
}
export function openLogOverlay() {
const overlay = document.getElementById('log-overlay');
if (overlay) {
overlay.style.display = 'flex';
// Initialize log filter icon select (once)
if (!_logFilterIconSelect) {
const filterSel = document.getElementById('log-viewer-filter');
if (filterSel) {
_logFilterIconSelect = new IconSelect({
target: filterSel,
items: _getLogFilterItems(),
columns: 2,
onChange: () => applyLogFilter(),
});
}
}
// Auto-connect when opening
if (!_logWs || _logWs.readyState !== WebSocket.OPEN) {
connectLogViewer();
}
}
}
export function closeLogOverlay() {
const overlay = document.getElementById('log-overlay');
if (overlay) overlay.style.display = 'none';
disconnectLogViewer();
}
// ─── Settings Modal ─────────────────────────────────────────
// Simple modal (no form / no dirty check needed) // Simple modal (no form / no dirty check needed)
const settingsModal = new Modal('settings-modal'); const settingsModal = new Modal('settings-modal');
let _logFilterIconSelect = null;
let _logLevelIconSelect = null; let _logLevelIconSelect = null;
const _LOG_LEVEL_ITEMS = [ /** Build log-level items lazily so t() has locale data loaded. */
function _getLogLevelItems() {
return [
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, { value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') }, { value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') }, { value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') }, { value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') }, { value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
]; ];
}
const _LOG_FILTER_ITEMS = [
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
];
export function openSettingsModal() { export function openSettingsModal() {
document.getElementById('settings-error').style.display = 'none'; document.getElementById('settings-error').style.display = 'none';
// Reset to first tab
switchSettingsTab('general');
settingsModal.open(); settingsModal.open();
// Initialize log filter icon select
if (!_logFilterIconSelect) {
const filterSel = document.getElementById('log-viewer-filter');
if (filterSel) {
_logFilterIconSelect = new IconSelect({
target: filterSel,
items: _LOG_FILTER_ITEMS,
columns: 2,
onChange: () => applyLogFilter(),
});
}
}
// Initialize log level icon select // Initialize log level icon select
if (!_logLevelIconSelect) { if (!_logLevelIconSelect) {
const levelSel = document.getElementById('settings-log-level'); const levelSel = document.getElementById('settings-log-level');
if (levelSel) { if (levelSel) {
_logLevelIconSelect = new IconSelect({ _logLevelIconSelect = new IconSelect({
target: levelSel, target: levelSel,
items: _LOG_LEVEL_ITEMS, items: _getLogLevelItems(),
columns: 3, columns: 3,
onChange: () => setLogLevel(), onChange: () => setLogLevel(),
}); });
@@ -179,6 +274,7 @@ export function openSettingsModal() {
} }
loadApiKeysList(); loadApiKeysList();
loadExternalUrl();
loadAutoBackupSettings(); loadAutoBackupSettings();
loadBackupList(); loadBackupList();
loadMqttSettings(); loadMqttSettings();
@@ -186,7 +282,6 @@ export function openSettingsModal() {
} }
export function closeSettingsModal() { export function closeSettingsModal() {
disconnectLogViewer();
settingsModal.forceClose(); settingsModal.forceClose();
} }

View File

@@ -710,6 +710,7 @@ export async function saveTemplate() {
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose(); templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates(); await loadCaptureTemplates();
} catch (error) { } catch (error) {
console.error('Error saving template:', error); console.error('Error saving template:', error);
@@ -729,6 +730,7 @@ export async function deleteTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('templates.deleted'), 'success'); showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates(); await loadCaptureTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting template:', error); console.error('Error deleting template:', error);
@@ -970,6 +972,7 @@ export async function saveAudioTemplate() {
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success'); showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose(); audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates(); await loadAudioTemplates();
} catch (error) { } catch (error) {
console.error('Error saving audio template:', error); console.error('Error saving audio template:', error);
@@ -989,6 +992,7 @@ export async function deleteAudioTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete audio template'); throw new Error(error.detail || error.message || 'Failed to delete audio template');
} }
showToast(t('audio_template.deleted'), 'success'); showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates(); await loadAudioTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting audio template:', error); console.error('Error deleting audio template:', error);
@@ -1027,13 +1031,20 @@ const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop
export async function showTestAudioTemplateModal(templateId) { export async function showTestAudioTemplateModal(templateId) {
_currentTestAudioTemplateId = templateId; _currentTestAudioTemplateId = templateId;
// Load audio devices for picker // Find template's engine type so we show the correct device list
const template = _cachedAudioTemplates.find(t => t.id === templateId);
const engineType = template ? template.engine_type : null;
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device'); const deviceSelect = document.getElementById('test-audio-template-device');
try { try {
const resp = await fetchWithAuth('/audio-devices'); const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
const devices = data.devices || []; // Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => { deviceSelect.innerHTML = devices.map(d => {
const label = d.name; const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
@@ -1078,10 +1089,11 @@ export function startAudioTemplateTest() {
const [devIdx, devLoop] = deviceVal.split(':'); const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal); localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button // Show canvas + stats, hide run button, disable device picker
document.getElementById('audio-template-test-canvas').style.display = ''; document.getElementById('audio-template-test-canvas').style.display = '';
document.getElementById('audio-template-test-stats').style.display = ''; document.getElementById('audio-template-test-stats').style.display = '';
document.getElementById('test-audio-template-start-btn').style.display = 'none'; document.getElementById('test-audio-template-start-btn').style.display = 'none';
document.getElementById('test-audio-template-device').disabled = true;
const statusEl = document.getElementById('audio-template-test-status'); const statusEl = document.getElementById('audio-template-test-status');
statusEl.textContent = t('audio_source.test.connecting'); statusEl.textContent = t('audio_source.test.connecting');
@@ -1140,6 +1152,9 @@ function _tplCleanupTest() {
_tplTestWs = null; _tplTestWs = null;
} }
_tplTestLatest = null; _tplTestLatest = null;
// Re-enable device picker
const devSel = document.getElementById('test-audio-template-device');
if (devSel) devSel.disabled = false;
} }
function _tplSizeCanvas(canvas) { function _tplSizeCanvas(canvas) {
@@ -1279,7 +1294,8 @@ const _streamSectionMap = {
proc_templates: [csProcTemplates], proc_templates: [csProcTemplates],
css_processing: [csCSPTemplates], css_processing: [csCSPTemplates],
color_strip: [csColorStrips], color_strip: [csColorStrips],
audio: [csAudioMulti, csAudioMono, csAudioTemplates], audio: [csAudioMulti, csAudioMono],
audio_templates: [csAudioTemplates],
value: [csValueSources], value: [csValueSources],
sync: [csSyncClocks], sync: [csSyncClocks],
}; };
@@ -1486,44 +1502,52 @@ function renderPictureSourcesList(streams) {
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length }, { key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length }, { key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, { key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
]; ];
// Build tree navigation structure // Build tree navigation structure
const treeGroups = [ const treeGroups = [
{
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
children: [
{ {
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture', key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
children: [ children: [
{ key: 'raw', titleKey: 'streams.group.raw', icon: getPictureSourceIcon('raw'), count: rawStreams.length }, { key: 'raw', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
{ key: 'raw_templates', titleKey: 'streams.group.raw_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length }, { key: 'raw_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
] ]
}, },
{ {
key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static',
count: staticImageStreams.length, children: [
}, { key: 'static_image', titleKey: 'tree.leaf.images', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length },
{ { key: 'video', titleKey: 'tree.leaf.video', icon: getPictureSourceIcon('video'), count: videoStreams.length },
key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video', ]
count: videoStreams.length,
}, },
{ {
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing', key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
children: [ children: [
{ key: 'processed', titleKey: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length }, { key: 'processed', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
{ key: 'proc_templates', titleKey: 'streams.group.proc_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length }, { key: 'proc_templates', titleKey: 'tree.leaf.filter_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
]
},
] ]
}, },
{ {
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip', key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
children: [ children: [
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length }, { key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length }, { key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
] ]
}, },
{ {
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio',
count: _cachedAudioSources.length + _cachedAudioTemplates.length, children: [
{ key: 'audio', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('multichannel'), count: _cachedAudioSources.length },
{ key: 'audio_templates', titleKey: 'tree.leaf.templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length },
]
}, },
{ {
key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility', key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
@@ -1555,7 +1579,7 @@ function renderPictureSourcesList(streams) {
const loopback = src.is_loopback !== false; const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`; const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null; const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : ''; const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`; propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
} }
@@ -1647,7 +1671,8 @@ function renderPictureSourcesList(streams) {
proc_templates: _cachedPPTemplates.length, proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length, css_processing: csptTemplates.length,
color_strip: colorStrips.length, color_strip: colorStrips.length,
audio: _cachedAudioSources.length + _cachedAudioTemplates.length, audio: _cachedAudioSources.length,
audio_templates: _cachedAudioTemplates.length,
value: _cachedValueSources.length, value: _cachedValueSources.length,
sync: _cachedSyncClocks.length, sync: _cachedSyncClocks.length,
}); });
@@ -1674,7 +1699,8 @@ function renderPictureSourcesList(streams) {
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems); else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems); else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems); else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems); else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
@@ -1695,7 +1721,8 @@ function renderPictureSourcesList(streams) {
'proc-streams': 'processed', 'proc-templates': 'proc_templates', 'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing', 'css-proc-templates': 'css_processing',
'color-strips': 'color_strip', 'color-strips': 'color_strip',
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio', 'audio-multi': 'audio', 'audio-mono': 'audio',
'audio-templates': 'audio_templates',
'value-sources': 'value', 'value-sources': 'value',
'sync-clocks': 'sync', 'sync-clocks': 'sync',
}); });
@@ -2089,6 +2116,7 @@ export async function saveStream() {
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success'); showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
streamModal.forceClose(); streamModal.forceClose();
streamsCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (error) { } catch (error) {
console.error('Error saving stream:', error); console.error('Error saving stream:', error);
@@ -2108,6 +2136,7 @@ export async function deleteStream(streamId) {
throw new Error(error.detail || error.message || 'Failed to delete stream'); throw new Error(error.detail || error.message || 'Failed to delete stream');
} }
showToast(t('streams.deleted'), 'success'); showToast(t('streams.deleted'), 'success');
streamsCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (error) { } catch (error) {
console.error('Error deleting stream:', error); console.error('Error deleting stream:', error);
@@ -2675,6 +2704,7 @@ export async function savePPTemplate() {
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success'); showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
ppTemplateModal.forceClose(); ppTemplateModal.forceClose();
ppTemplatesCache.invalidate();
await loadPPTemplates(); await loadPPTemplates();
} catch (error) { } catch (error) {
console.error('Error saving PP template:', error); console.error('Error saving PP template:', error);
@@ -2735,6 +2765,7 @@ export async function deletePPTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('postprocessing.deleted'), 'success'); showToast(t('postprocessing.deleted'), 'success');
ppTemplatesCache.invalidate();
await loadPPTemplates(); await loadPPTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting PP template:', error); console.error('Error deleting PP template:', error);
@@ -2888,6 +2919,7 @@ export async function saveCSPT() {
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success'); showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
csptModal.forceClose(); csptModal.forceClose();
csptCache.invalidate();
await loadCSPTemplates(); await loadCSPTemplates();
} catch (error) { } catch (error) {
console.error('Error saving CSPT:', error); console.error('Error saving CSPT:', error);
@@ -2920,6 +2952,7 @@ export async function deleteCSPT(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('css_processing.deleted'), 'success'); showToast(t('css_processing.deleted'), 'success');
csptCache.invalidate();
await loadCSPTemplates(); await loadCSPTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting CSPT:', error); console.error('Error deleting CSPT:', error);

View File

@@ -98,6 +98,7 @@ export async function saveSyncClock() {
} }
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success'); showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose(); syncClockModal.forceClose();
syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('sync_clock.deleted'), 'success'); showToast(t('sync_clock.deleted'), 'success');
syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations. * This module manages the editor modal and API operations.
*/ */
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js'; import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
@@ -486,6 +486,7 @@ export async function saveValueSource() {
} }
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success'); showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose(); valueSourceModal.forceClose();
valueSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
errorEl.textContent = e.message; errorEl.textContent = e.message;
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('value_source.deleted'), 'success'); showToast(t('value_source.deleted'), 'success');
valueSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');

View File

@@ -312,6 +312,16 @@
"device.tip.webui": "Open the device's built-in web interface for advanced configuration", "device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new LED device", "device.tip.add": "Click here to add a new LED device",
"settings.title": "Settings", "settings.title": "Settings",
"settings.tab.general": "General",
"settings.tab.backup": "Backup",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "Open Log Viewer",
"settings.external_url.label": "External URL",
"settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080",
"settings.external_url.placeholder": "https://myserver.example.com:8080",
"settings.external_url.save": "Save",
"settings.external_url.saved": "External URL saved",
"settings.external_url.save_error": "Failed to save external URL",
"settings.general.title": "General Settings", "settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings", "settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
@@ -443,6 +453,7 @@
"streams.group.css_processing": "Processing Templates", "streams.group.css_processing": "Processing Templates",
"streams.group.color_strip": "Color Strips", "streams.group.color_strip": "Color Strips",
"streams.group.audio": "Audio", "streams.group.audio": "Audio",
"streams.group.audio_templates": "Audio Templates",
"streams.section.streams": "Sources", "streams.section.streams": "Sources",
"streams.add": "Add Source", "streams.add": "Add Source",
"streams.add.raw": "Add Screen Capture", "streams.add.raw": "Add Screen Capture",
@@ -1076,6 +1087,7 @@
"color_strip.test.error": "Failed to connect to preview stream", "color_strip.test.error": "Failed to connect to preview stream",
"color_strip.test.led_count": "LEDs:", "color_strip.test.led_count": "LEDs:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "Receive FPS",
"color_strip.test.apply": "Apply", "color_strip.test.apply": "Apply",
"color_strip.test.composite": "Composite", "color_strip.test.composite": "Composite",
"color_strip.preview.title": "Live Preview", "color_strip.preview.title": "Live Preview",
@@ -1108,7 +1120,7 @@
"color_strip.type.processed": "Processed", "color_strip.type.processed": "Processed",
"color_strip.type.processed.desc": "Apply a processing template to another source", "color_strip.type.processed.desc": "Apply a processing template to another source",
"color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.", "color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.",
"color_strip.processed.input": "Input Source:", "color_strip.processed.input": "Source:",
"color_strip.processed.input.hint": "The color strip source whose output will be processed", "color_strip.processed.input.hint": "The color strip source whose output will be processed",
"color_strip.processed.template": "Processing Template:", "color_strip.processed.template": "Processing Template:",
"color_strip.processed.template.hint": "Filter chain to apply to the input source output", "color_strip.processed.template.hint": "Filter chain to apply to the input source output",
@@ -1126,6 +1138,8 @@
"color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors", "color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors",
"color_strip.composite.blend_mode.screen": "Screen", "color_strip.composite.blend_mode.screen": "Screen",
"color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply",
"color_strip.composite.blend_mode.override": "Override",
"color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque",
"color_strip.composite.opacity": "Opacity", "color_strip.composite.opacity": "Opacity",
"color_strip.composite.brightness": "Brightness", "color_strip.composite.brightness": "Brightness",
"color_strip.composite.brightness.none": "None (full brightness)", "color_strip.composite.brightness.none": "None (full brightness)",
@@ -1269,11 +1283,20 @@
"audio_template.error.delete": "Failed to delete audio template", "audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources", "streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks", "streams.group.sync": "Sync Clocks",
"tree.group.picture": "Picture Source",
"tree.group.capture": "Screen Capture", "tree.group.capture": "Screen Capture",
"tree.group.static": "Static",
"tree.group.processing": "Processed", "tree.group.processing": "Processed",
"tree.group.picture": "Picture",
"tree.group.strip": "Color Strip", "tree.group.strip": "Color Strip",
"tree.group.audio": "Audio",
"tree.group.utility": "Utility", "tree.group.utility": "Utility",
"tree.leaf.sources": "Sources",
"tree.leaf.engine_templates": "Engine Templates",
"tree.leaf.images": "Images",
"tree.leaf.video": "Video",
"tree.leaf.filter_templates": "Filter Templates",
"tree.leaf.processing_templates": "Processing Templates",
"tree.leaf.templates": "Templates",
"value_source.group.title": "Value Sources", "value_source.group.title": "Value Sources",
"value_source.select_type": "Select Value Source Type", "value_source.select_type": "Select Value Source Type",
"value_source.add": "Add Value Source", "value_source.add": "Add Value Source",
@@ -1649,6 +1672,9 @@
"graph.help.drag_port_desc": "Connect entities", "graph.help.drag_port_desc": "Connect entities",
"graph.help.right_click": "Right-click edge", "graph.help.right_click": "Right-click edge",
"graph.help.right_click_desc": "Detach connection", "graph.help.right_click_desc": "Detach connection",
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Errors",
"graph.tooltip.uptime": "Uptime",
"automation.enabled": "Automation enabled", "automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled", "automation.disabled": "Automation disabled",
"scene_preset.activated": "Preset activated", "scene_preset.activated": "Preset activated",

View File

@@ -312,6 +312,16 @@
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки", "device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.tab.general": "Основные",
"settings.tab.backup": "Бэкап",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "Открыть логи",
"settings.external_url.label": "Внешний URL",
"settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080",
"settings.external_url.placeholder": "https://myserver.example.com:8080",
"settings.external_url.save": "Сохранить",
"settings.external_url.saved": "Внешний URL сохранён",
"settings.external_url.save_error": "Не удалось сохранить внешний URL",
"settings.general.title": "Основные Настройки", "settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата", "settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
@@ -443,6 +453,7 @@
"streams.group.css_processing": "Шаблоны Обработки", "streams.group.css_processing": "Шаблоны Обработки",
"streams.group.color_strip": "Цветовые Полосы", "streams.group.color_strip": "Цветовые Полосы",
"streams.group.audio": "Аудио", "streams.group.audio": "Аудио",
"streams.group.audio_templates": "Аудио шаблоны",
"streams.section.streams": "Источники", "streams.section.streams": "Источники",
"streams.add": "Добавить Источник", "streams.add": "Добавить Источник",
"streams.add.raw": "Добавить Захват Экрана", "streams.add.raw": "Добавить Захват Экрана",
@@ -1076,6 +1087,7 @@
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
"color_strip.test.led_count": "Кол-во LED:", "color_strip.test.led_count": "Кол-во LED:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "Частота приёма",
"color_strip.test.apply": "Применить", "color_strip.test.apply": "Применить",
"color_strip.test.composite": "Композит", "color_strip.test.composite": "Композит",
"color_strip.preview.title": "Предпросмотр", "color_strip.preview.title": "Предпросмотр",
@@ -1108,7 +1120,7 @@
"color_strip.type.processed": "Обработанный", "color_strip.type.processed": "Обработанный",
"color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику", "color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику",
"color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.", "color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.",
"color_strip.processed.input": "Входной источник:", "color_strip.processed.input": "Источник:",
"color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан", "color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан",
"color_strip.processed.template": "Шаблон обработки:", "color_strip.processed.template": "Шаблон обработки:",
"color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника", "color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника",
@@ -1126,6 +1138,8 @@
"color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета", "color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета",
"color_strip.composite.blend_mode.screen": "Экран", "color_strip.composite.blend_mode.screen": "Экран",
"color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение",
"color_strip.composite.blend_mode.override": "Замена",
"color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный",
"color_strip.composite.opacity": "Непрозрачность", "color_strip.composite.opacity": "Непрозрачность",
"color_strip.composite.brightness": "Яркость", "color_strip.composite.brightness": "Яркость",
"color_strip.composite.brightness.none": "Нет (полная яркость)", "color_strip.composite.brightness.none": "Нет (полная яркость)",
@@ -1269,11 +1283,20 @@
"audio_template.error.delete": "Не удалось удалить аудиошаблон", "audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений", "streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации", "streams.group.sync": "Часы синхронизации",
"tree.group.capture": "Захват Экрана", "tree.group.picture": "Источники изображений",
"tree.group.capture": "Захват экрана",
"tree.group.static": "Статичные",
"tree.group.processing": "Обработанные", "tree.group.processing": "Обработанные",
"tree.group.picture": "Изображения", "tree.group.strip": "Цветовые полосы",
"tree.group.strip": "Цветовые Полосы", "tree.group.audio": "Аудио",
"tree.group.utility": "Утилиты", "tree.group.utility": "Утилиты",
"tree.leaf.sources": "Источники",
"tree.leaf.engine_templates": "Шаблоны движка",
"tree.leaf.images": "Изображения",
"tree.leaf.video": "Видео",
"tree.leaf.filter_templates": "Шаблоны фильтров",
"tree.leaf.processing_templates": "Шаблоны обработки",
"tree.leaf.templates": "Шаблоны",
"value_source.group.title": "Источники значений", "value_source.group.title": "Источники значений",
"value_source.select_type": "Выберите тип источника значений", "value_source.select_type": "Выберите тип источника значений",
"value_source.add": "Добавить источник значений", "value_source.add": "Добавить источник значений",
@@ -1649,6 +1672,9 @@
"graph.help.drag_port_desc": "Соединить сущности", "graph.help.drag_port_desc": "Соединить сущности",
"graph.help.right_click": "ПКМ по связи", "graph.help.right_click": "ПКМ по связи",
"graph.help.right_click_desc": "Отсоединить связь", "graph.help.right_click_desc": "Отсоединить связь",
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Ошибки",
"graph.tooltip.uptime": "Время работы",
"automation.enabled": "Автоматизация включена", "automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена", "automation.disabled": "Автоматизация выключена",
"scene_preset.activated": "Пресет активирован", "scene_preset.activated": "Пресет активирован",

View File

@@ -312,6 +312,16 @@
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置", "device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.add": "点击此处添加新的 LED 设备", "device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设置", "settings.title": "设置",
"settings.tab.general": "常规",
"settings.tab.backup": "备份",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "打开日志查看器",
"settings.external_url.label": "外部 URL",
"settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例https://myserver.example.com:8080",
"settings.external_url.placeholder": "https://myserver.example.com:8080",
"settings.external_url.save": "保存",
"settings.external_url.saved": "外部 URL 已保存",
"settings.external_url.save_error": "保存外部 URL 失败",
"settings.general.title": "常规设置", "settings.general.title": "常规设置",
"settings.capture.title": "采集设置", "settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新", "settings.capture.saved": "采集设置已更新",
@@ -443,6 +453,7 @@
"streams.group.css_processing": "处理模板", "streams.group.css_processing": "处理模板",
"streams.group.color_strip": "色带源", "streams.group.color_strip": "色带源",
"streams.group.audio": "音频", "streams.group.audio": "音频",
"streams.group.audio_templates": "音频模板",
"streams.section.streams": "源", "streams.section.streams": "源",
"streams.add": "添加源", "streams.add": "添加源",
"streams.add.raw": "添加屏幕采集", "streams.add.raw": "添加屏幕采集",
@@ -1076,6 +1087,7 @@
"color_strip.test.error": "无法连接到预览流", "color_strip.test.error": "无法连接到预览流",
"color_strip.test.led_count": "LED数量:", "color_strip.test.led_count": "LED数量:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "接收帧率",
"color_strip.test.apply": "应用", "color_strip.test.apply": "应用",
"color_strip.test.composite": "合成", "color_strip.test.composite": "合成",
"color_strip.preview.title": "实时预览", "color_strip.preview.title": "实时预览",
@@ -1108,7 +1120,7 @@
"color_strip.type.processed": "已处理", "color_strip.type.processed": "已处理",
"color_strip.type.processed.desc": "将处理模板应用于另一个源", "color_strip.type.processed.desc": "将处理模板应用于另一个源",
"color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。", "color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。",
"color_strip.processed.input": "输入源:", "color_strip.processed.input": "源:",
"color_strip.processed.input.hint": "将被处理的色带源", "color_strip.processed.input.hint": "将被处理的色带源",
"color_strip.processed.template": "处理模板:", "color_strip.processed.template": "处理模板:",
"color_strip.processed.template.hint": "应用于输入源输出的滤镜链", "color_strip.processed.template.hint": "应用于输入源输出的滤镜链",
@@ -1126,6 +1138,8 @@
"color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗", "color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗",
"color_strip.composite.blend_mode.screen": "滤色", "color_strip.composite.blend_mode.screen": "滤色",
"color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转",
"color_strip.composite.blend_mode.override": "覆盖",
"color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明",
"color_strip.composite.opacity": "不透明度", "color_strip.composite.opacity": "不透明度",
"color_strip.composite.brightness": "亮度", "color_strip.composite.brightness": "亮度",
"color_strip.composite.brightness.none": "无(全亮度)", "color_strip.composite.brightness.none": "无(全亮度)",
@@ -1269,11 +1283,20 @@
"audio_template.error.delete": "删除音频模板失败", "audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源", "streams.group.value": "值源",
"streams.group.sync": "同步时钟", "streams.group.sync": "同步时钟",
"tree.group.picture": "图片源",
"tree.group.capture": "屏幕采集", "tree.group.capture": "屏幕采集",
"tree.group.static": "静态",
"tree.group.processing": "已处理", "tree.group.processing": "已处理",
"tree.group.picture": "图片",
"tree.group.strip": "色带", "tree.group.strip": "色带",
"tree.group.audio": "音频",
"tree.group.utility": "工具", "tree.group.utility": "工具",
"tree.leaf.sources": "源",
"tree.leaf.engine_templates": "引擎模板",
"tree.leaf.images": "图片",
"tree.leaf.video": "视频",
"tree.leaf.filter_templates": "滤镜模板",
"tree.leaf.processing_templates": "处理模板",
"tree.leaf.templates": "模板",
"value_source.group.title": "值源", "value_source.group.title": "值源",
"value_source.select_type": "选择值源类型", "value_source.select_type": "选择值源类型",
"value_source.add": "添加值源", "value_source.add": "添加值源",
@@ -1649,6 +1672,9 @@
"graph.help.drag_port_desc": "连接实体", "graph.help.drag_port_desc": "连接实体",
"graph.help.right_click": "右键边线", "graph.help.right_click": "右键边线",
"graph.help.right_click_desc": "断开连接", "graph.help.right_click_desc": "断开连接",
"graph.tooltip.fps": "帧率",
"graph.tooltip.errors": "错误",
"graph.tooltip.uptime": "运行时间",
"automation.enabled": "自动化已启用", "automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用", "automation.disabled": "自动化已禁用",
"scene_preset.activated": "预设已激活", "scene_preset.activated": "预设已激活",

View File

@@ -227,7 +227,7 @@ class ColorStripSource:
return ApiInputColorStripSource( return ApiInputColorStripSource(
id=sid, name=name, source_type="api_input", id=sid, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, description=description, created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id, tags=tags, led_count=data.get("led_count") or 0, clock_id=clock_id, tags=tags,
fallback_color=fallback_color, fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0), timeout=float(data.get("timeout") or 5.0),
) )
@@ -792,16 +792,14 @@ class ApiInputColorStripSource(ColorStripSource):
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`. received within `timeout` seconds, LEDs revert to `fallback_color`.
LED count auto-sizes from the connected device when led_count == 0. LED count auto-sizes from the connected device via configure().
""" """
led_count: int = 0 # 0 = auto-size from device
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B] fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
timeout: float = 5.0 # seconds before reverting to fallback timeout: float = 5.0 # seconds before reverting to fallback
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["led_count"] = self.led_count
d["fallback_color"] = list(self.fallback_color) d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout d["timeout"] = self.timeout
return d return d
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
def create_from_kwargs(cls, *, id: str, name: str, source_type: str, def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
led_count=0, fallback_color=None, timeout=None, fallback_color=None, timeout=None,
**_kwargs): **_kwargs):
fb = _validate_rgb(fallback_color, [0, 0, 0]) fb = _validate_rgb(fallback_color, [0, 0, 0])
return cls( return cls(
id=id, name=name, source_type="api_input", id=id, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, created_at=created_at, updated_at=updated_at,
description=description, clock_id=clock_id, tags=tags or [], description=description, clock_id=clock_id, tags=tags or [],
led_count=led_count, fallback_color=fb, fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0, timeout=float(timeout) if timeout is not None else 5.0,
) )
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
self.fallback_color = fallback_color self.fallback_color = fallback_color
if kwargs.get("timeout") is not None: if kwargs.get("timeout") is not None:
self.timeout = float(kwargs["timeout"]) self.timeout = float(kwargs["timeout"])
if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"]
@dataclass @dataclass

View File

@@ -567,7 +567,7 @@
<div id="css-editor-processed-section" style="display:none"> <div id="css-editor-processed-section" style="display:none">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Input Source:</label> <label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small> <small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small>

View File

@@ -1,11 +1,21 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title"> <div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="modal-content" style="max-width: 450px;"> <div class="modal-content" style="max-width: 480px;">
<div class="modal-header"> <div class="modal-header">
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2> <h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<!-- Tab bar -->
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- ═══ General tab ═══ -->
<div id="settings-panel-general" class="settings-panel active">
<!-- API Keys section (read-only) --> <!-- API Keys section (read-only) -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -16,6 +26,54 @@
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div> <div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div> </div>
<!-- External URL -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.external_url.label">External URL</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.external_url.hint">If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080</small>
<div style="display:flex;gap:0.5rem;">
<input type="text" id="settings-external-url" placeholder="https://myserver.example.com:8080" style="flex:1" data-i18n-placeholder="settings.external_url.placeholder">
<button class="btn btn-primary" onclick="saveExternalUrl()" data-i18n="settings.external_url.save">Save</button>
</div>
</div>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<select id="settings-log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<!-- Server Logs button (opens overlay) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
</div>
<button class="btn btn-secondary" onclick="openLogOverlay()" style="width:100%" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
</div>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div>
</div>
<!-- ═══ Backup tab ═══ -->
<div id="settings-panel-backup" class="settings-panel">
<!-- Backup section --> <!-- Backup section -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -119,8 +177,10 @@
<small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small> <small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small>
<div id="saved-backups-list"></div> <div id="saved-backups-list"></div>
</div> </div>
</div>
<!-- MQTT section --> <!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label> <label data-i18n="settings.mqtt.label">MQTT</label>
@@ -169,51 +229,6 @@
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button> <button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div> </div>
<!-- Server Logs section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
<option value="all" data-i18n="settings.logs.filter.all">All</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<select id="settings-log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div> </div>
<div id="settings-error" class="error-message" style="display:none;"></div> <div id="settings-error" class="error-message" style="display:none;"></div>
@@ -223,3 +238,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Log Viewer Overlay (full-screen, independent of settings modal) -->
<div id="log-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 data-i18n="settings.logs.label">Server Logs</h3>
<select id="log-viewer-filter" onchange="applyLogFilter()">
<option value="all" data-i18n="settings.logs.filter.all">All levels</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
<button id="log-viewer-connect-btn" class="btn btn-secondary btn-sm" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary btn-sm" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>

View File

@@ -46,12 +46,12 @@
<!-- CSPT test: input source selector (hidden by default) --> <!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control"> <div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Input Source:</label> <label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select> <select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
</div> </div>
<!-- LED count & FPS controls --> <!-- LED count & FPS controls -->
<div class="css-test-led-control"> <div id="css-test-led-fps-group" class="css-test-led-control">
<span id="css-test-led-group"> <span id="css-test-led-group">
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label> <label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input"> <input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
@@ -62,6 +62,17 @@
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button> <button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button>
</div> </div>
<!-- FPS chart (for api_input sources) — matches target card sparkline -->
<div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
<div class="target-fps-sparkline">
<canvas id="css-test-fps-chart"></canvas>
</div>
<div class="target-fps-label">
<span id="css-test-fps-value" class="metric-value">0</span>
<span class="target-fps-avg" id="css-test-fps-avg"></span>
</div>
</div>
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div> <div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
</div> </div>
</div> </div>