Compare commits
5 Commits
823cb90d2d
...
05152a0f51
| Author | SHA1 | Date | |
|---|---|---|---|
| 05152a0f51 | |||
| 191c988cf9 | |||
| afd4a3bc05 | |||
| be356f30eb | |||
| 8a6ffca446 |
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||
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:
|
||||
"""Activate a scene preset."""
|
||||
async with self.session.post(
|
||||
@@ -352,6 +384,21 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
resp.raise_for_status()
|
||||
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:
|
||||
"""Update a output target's fields."""
|
||||
async with self.session.put(
|
||||
|
||||
147
custom_components/wled_screen_controller/light.py
Normal file
147
custom_components/wled_screen_controller/light.py
Normal 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]
|
||||
19
custom_components/wled_screen_controller/services.yaml
Normal file
19
custom_components/wled_screen_controller/services.yaml
Normal 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:
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -66,5 +71,21 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
webhook_url = None
|
||||
for c in automation.conditions:
|
||||
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}"
|
||||
else:
|
||||
webhook_url = f"/api/v1/webhooks/{c.token}"
|
||||
|
||||
@@ -410,7 +410,8 @@ async def push_colors(
|
||||
):
|
||||
"""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:
|
||||
source = store.get_source(source_id)
|
||||
@@ -420,20 +421,32 @@ async def push_colors(
|
||||
if not isinstance(source, ApiInputColorStripSource):
|
||||
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
||||
|
||||
colors_array = np.array(body.colors, dtype=np.uint8)
|
||||
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")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"leds_received": len(body.colors),
|
||||
}
|
||||
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)
|
||||
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")
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"leds_received": len(body.colors),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
|
||||
@@ -708,19 +721,42 @@ async def css_api_input_ws(
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
# JSON frame: {"colors": [[R,G,B], ...]}
|
||||
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
|
||||
import json
|
||||
try:
|
||||
data = json.loads(message["text"])
|
||||
raw_colors = data.get("colors", [])
|
||||
colors_array = np.array(raw_colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
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)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (ValueError, TypeError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
else:
|
||||
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
raw_bytes = message["bytes"]
|
||||
@@ -732,7 +768,7 @@ async def css_api_input_ws(
|
||||
else:
|
||||
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)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
|
||||
try:
|
||||
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
|
||||
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
||||
is_composite = isinstance(source, CompositeColorStripSource)
|
||||
@@ -875,9 +915,18 @@ async def test_color_strip_ws(
|
||||
elif composite_colors is not None:
|
||||
await websocket.send_bytes(composite_colors.tobytes())
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
# 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:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
|
||||
# Periodically send auxiliary data (frame preview, brightness)
|
||||
now = _time.monotonic()
|
||||
|
||||
@@ -43,6 +43,8 @@ from wled_controller.api.schemas.system import (
|
||||
BackupListResponse,
|
||||
DisplayInfo,
|
||||
DisplayListResponse,
|
||||
ExternalUrlRequest,
|
||||
ExternalUrlResponse,
|
||||
GpuInfo,
|
||||
HealthResponse,
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
@@ -31,7 +31,7 @@ class CompositeLayer(BaseModel):
|
||||
"""A single layer in a composite 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")
|
||||
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")
|
||||
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source."""
|
||||
class SegmentPayload(BaseModel):
|
||||
"""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):
|
||||
|
||||
@@ -143,6 +143,20 @@ class MQTTSettingsRequest(BaseModel):
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
class LogLevelResponse(BaseModel):
|
||||
|
||||
@@ -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
|
||||
received within `timeout` seconds, LEDs revert to `fallback_color`.
|
||||
|
||||
Thread-safe: push_colors() can be called from any thread (REST handler,
|
||||
WebSocket handler) while get_latest_colors() is called from the target
|
||||
processor thread.
|
||||
Thread-safe: push_colors() / push_segments() can be called from any thread
|
||||
(REST handler, WebSocket handler) while get_latest_colors() is called from
|
||||
the target processor thread.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_DEFAULT_LED_COUNT = 150
|
||||
|
||||
|
||||
class ApiInputColorStripStream(ColorStripStream):
|
||||
"""Color strip stream backed by externally-pushed LED color data.
|
||||
|
||||
Holds a thread-safe np.ndarray buffer. External clients push colors via
|
||||
push_colors(). A background thread checks for timeout and reverts to
|
||||
fallback_color when no data arrives within the configured timeout window.
|
||||
push_colors() or push_segments(). A background thread checks for timeout
|
||||
and reverts to fallback_color when no data arrives within the configured
|
||||
timeout window.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
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._auto_size = not source.led_count
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._led_count = _DEFAULT_LED_COUNT
|
||||
|
||||
# Build initial fallback buffer
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
self._last_push_time: float = 0.0
|
||||
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:
|
||||
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
|
||||
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
(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:
|
||||
"""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:
|
||||
colors: np.ndarray shape (N, 3) uint8
|
||||
"""
|
||||
with self._lock:
|
||||
n = len(colors)
|
||||
# Auto-grow if incoming data is larger
|
||||
if n > self._led_count:
|
||||
self._ensure_capacity(n)
|
||||
if n == self._led_count:
|
||||
self._colors = colors.astype(np.uint8)
|
||||
elif n > self._led_count:
|
||||
self._colors = colors[:self._led_count].astype(np.uint8)
|
||||
else:
|
||||
elif n < self._led_count:
|
||||
# Zero-pad to led_count
|
||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
padded[:n] = colors[:n]
|
||||
self._colors = padded
|
||||
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
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
"""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:
|
||||
self._led_count = device_led_count
|
||||
self._fallback_array = self._build_fallback(device_led_count)
|
||||
self._colors = self._fallback_array.copy()
|
||||
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
|
||||
def target_fps(self) -> int:
|
||||
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
with self._lock:
|
||||
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:
|
||||
"""Hot-update fallback_color and timeout from updated source config."""
|
||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||
@@ -138,19 +230,10 @@ class ApiInputColorStripStream(ColorStripStream):
|
||||
fallback = source.fallback_color
|
||||
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)
|
||||
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:
|
||||
self._fallback_array = self._build_fallback(self._led_count)
|
||||
if self._timed_out:
|
||||
self._colors = self._fallback_array.copy()
|
||||
logger.info("ApiInputColorStripStream params updated in-place")
|
||||
|
||||
def _timeout_loop(self) -> None:
|
||||
|
||||
@@ -16,6 +16,7 @@ _BLEND_NORMAL = "normal"
|
||||
_BLEND_ADD = "add"
|
||||
_BLEND_MULTIPLY = "multiply"
|
||||
_BLEND_SCREEN = "screen"
|
||||
_BLEND_OVERRIDE = "override"
|
||||
|
||||
|
||||
class CompositeColorStripStream(ColorStripStream):
|
||||
@@ -300,11 +301,34 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
u16a >>= 8
|
||||
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_NORMAL: "_blend_normal",
|
||||
_BLEND_ADD: "_blend_add",
|
||||
_BLEND_MULTIPLY: "_blend_multiply",
|
||||
_BLEND_SCREEN: "_blend_screen",
|
||||
_BLEND_OVERRIDE: "_blend_override",
|
||||
}
|
||||
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
@@ -1172,3 +1172,62 @@ html:has(#tab-graph.active) {
|
||||
background: var(--border-color);
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
|
||||
|
||||
/* Composite layers preview */
|
||||
.css-test-layers {
|
||||
display: flex;
|
||||
@@ -346,7 +348,107 @@
|
||||
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 {
|
||||
background: #0d0d0d;
|
||||
@@ -1414,6 +1516,19 @@
|
||||
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 {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -99,6 +99,17 @@
|
||||
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) ── */
|
||||
|
||||
.tree-children {
|
||||
|
||||
@@ -182,13 +182,16 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
|
||||
import { navigateToCard } from './core/navigation.js';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.js';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
@@ -522,9 +525,10 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
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,
|
||||
closeSettingsModal,
|
||||
switchSettingsTab,
|
||||
downloadBackup,
|
||||
handleRestoreFileSelected,
|
||||
saveAutoBackupSettings,
|
||||
@@ -540,8 +544,12 @@ Object.assign(window, {
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
applyLogFilter,
|
||||
openLogOverlay,
|
||||
closeLogOverlay,
|
||||
loadLogLevel,
|
||||
setLogLevel,
|
||||
saveExternalUrl,
|
||||
getBaseOrigin,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
@@ -569,8 +577,11 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Close in order: overlay lightboxes first, then modals via stack
|
||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
// Close in order: log overlay > overlay lightboxes > modals via stack
|
||||
const logOverlay = document.getElementById('log-overlay');
|
||||
if (logOverlay && logOverlay.style.display !== 'none') {
|
||||
closeLogOverlay();
|
||||
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
closeDisplayPicker();
|
||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||
closeLightbox();
|
||||
@@ -605,6 +616,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
||||
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
|
||||
initTabs();
|
||||
|
||||
|
||||
@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
|
||||
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) {
|
||||
const cache = ENTITY_CACHE_MAP[entityType];
|
||||
if (cache) {
|
||||
cache.fetch({ force: true });
|
||||
}
|
||||
if (!cache) return;
|
||||
|
||||
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', {
|
||||
detail: { entity_type: entityType },
|
||||
}));
|
||||
|
||||
@@ -292,7 +292,7 @@ function renderNode(node, callbacks) {
|
||||
class: 'graph-node-title',
|
||||
x: 16, y: 24,
|
||||
});
|
||||
title.textContent = truncate(name, 18);
|
||||
title.textContent = name;
|
||||
g.appendChild(title);
|
||||
|
||||
// Subtitle (type)
|
||||
@@ -305,11 +305,6 @@ function renderNode(node, callbacks) {
|
||||
g.appendChild(sub);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
const tip = svgEl('title');
|
||||
tip.textContent = `${name} (${kind.replace(/_/g, ' ')})`;
|
||||
g.appendChild(tip);
|
||||
|
||||
// Hover overlay (action buttons)
|
||||
const overlay = _createOverlay(node, width, callbacks);
|
||||
g.appendChild(overlay);
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
|
||||
* 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)
|
||||
* ]
|
||||
*/
|
||||
@@ -25,6 +28,12 @@ function _saveCollapsed(key, collapsed) {
|
||||
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 {
|
||||
/**
|
||||
* @param {string} containerId - ID of the nav element to render into
|
||||
@@ -71,15 +80,22 @@ export class TreeNav {
|
||||
const leaf = this._leafMap.get(key);
|
||||
if (leaf) leaf.count = count;
|
||||
}
|
||||
// Update group counts
|
||||
container.querySelectorAll('[data-tree-group]').forEach(groupEl => {
|
||||
// Update group counts (bottom-up: deepest first)
|
||||
const groups = [...container.querySelectorAll('[data-tree-group]')];
|
||||
groups.reverse();
|
||||
for (const groupEl of groups) {
|
||||
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;
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
|
||||
@@ -116,11 +132,13 @@ export class TreeNav {
|
||||
|
||||
_buildLeafMap() {
|
||||
this._leafMap.clear();
|
||||
for (const item of this._items) {
|
||||
this._collectLeaves(this._items);
|
||||
}
|
||||
|
||||
_collectLeaves(items) {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
this._leafMap.set(child.key, child);
|
||||
}
|
||||
this._collectLeaves(item.children);
|
||||
} else {
|
||||
this._leafMap.set(item.key, item);
|
||||
}
|
||||
@@ -135,7 +153,7 @@ export class TreeNav {
|
||||
|
||||
const html = this._items.map(item => {
|
||||
if (item.children) {
|
||||
return this._renderGroup(item, collapsed);
|
||||
return this._renderGroup(item, collapsed, 0);
|
||||
}
|
||||
return this._renderStandalone(item);
|
||||
}).join('');
|
||||
@@ -145,12 +163,24 @@ export class TreeNav {
|
||||
this._bindEvents(container);
|
||||
}
|
||||
|
||||
_renderGroup(group, collapsed) {
|
||||
_renderGroup(group, collapsed, depth) {
|
||||
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 `
|
||||
<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}">
|
||||
<span class="tree-chevron${isCollapsed ? '' : ' open'}">▶</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>
|
||||
</div>
|
||||
<div class="tree-children${isCollapsed ? ' collapsed' : ''}">
|
||||
${group.children.map(leaf => `
|
||||
<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('')}
|
||||
${childrenHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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 { t } from '../core/i18n.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');
|
||||
audioSourceModal.forceClose();
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('audio_source.deleted'), 'success');
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -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 { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { getBaseOrigin } from './settings.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
@@ -546,7 +547,7 @@ function addAutomationConditionRow(condition) {
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
if (data.token) {
|
||||
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token;
|
||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
|
||||
@@ -706,6 +707,7 @@ export async function saveAutomationEditor() {
|
||||
|
||||
automationModal.forceClose();
|
||||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -720,6 +722,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -767,6 +770,7 @@ export async function deleteAutomation(automationId, automationName) {
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to delete automation');
|
||||
showToast(t('automations.deleted'), 'success');
|
||||
automationsCacheObj.invalidate();
|
||||
loadAutomations();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
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 { t } from '../core/i18n.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 { IconSelect, showTypePicker } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { getBaseOrigin } from './settings.js';
|
||||
import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||
@@ -198,8 +200,8 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — only shown for picture, picture_advanced, api_input
|
||||
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
|
||||
// LED count — only shown for picture, picture_advanced
|
||||
const hasLedCount = ['picture', 'picture_advanced'];
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
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: '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: '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="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="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>`;
|
||||
return;
|
||||
}
|
||||
const base = `${window.location.origin}/api/v1`;
|
||||
const base = `${getBaseOrigin()}/api/v1`;
|
||||
const url = `${base}/color-strip-sources/${cssId}/notify`;
|
||||
el.innerHTML = `
|
||||
<small class="endpoint-label">POST</small>
|
||||
@@ -1656,9 +1660,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = !isApiInput
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-css-id',
|
||||
@@ -2259,6 +2261,7 @@ export async function saveCSSEditor() {
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
const base = `${window.location.origin}/api/v1`;
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
|
||||
const origin = getBaseOrigin();
|
||||
const base = `${origin}/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 apiKey = localStorage.getItem('wled_api_key') || '';
|
||||
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
@@ -2335,6 +2340,7 @@ export async function deleteColorStrip(cssId) {
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
if (window.loadPictureSources) window.loadPictureSources();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
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 _cssTestCSPTMode = false; // true when testing a CSPT template
|
||||
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;
|
||||
|
||||
function _getCssTestLedCount() {
|
||||
@@ -2488,12 +2499,32 @@ function _getCssTestFps() {
|
||||
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) {
|
||||
_cssTestCSPTMode = false;
|
||||
_cssTestCSPTId = null;
|
||||
// Hide CSPT input selector
|
||||
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||
if (csptGroup) csptGroup.style.display = 'none';
|
||||
// Detect api_input type
|
||||
const sources = colorStripSourcesCache.data || [];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2503,25 +2534,9 @@ export async function testCSPT(templateId) {
|
||||
|
||||
// Populate input source selector
|
||||
await colorStripSourcesCache.fetch();
|
||||
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}">${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 = '';
|
||||
_populateCssTestSourceSelector(null);
|
||||
|
||||
const sel = document.getElementById('css-test-cspt-input-select');
|
||||
const inputId = sel.value;
|
||||
if (!inputId) {
|
||||
showToast(t('color_strip.processed.error.no_input'), 'error');
|
||||
@@ -2550,23 +2565,42 @@ function _openTestModal(sourceId) {
|
||||
document.getElementById('css-test-rect-view').style.display = 'none';
|
||||
document.getElementById('css-test-layers-view').style.display = 'none';
|
||||
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');
|
||||
if (layersContainer) layersContainer.innerHTML = '';
|
||||
document.getElementById('css-test-status').style.display = '';
|
||||
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
||||
|
||||
// Restore LED count + FPS + Enter key handlers
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// Reset FPS tracking
|
||||
_cssTestFpsHistory = [];
|
||||
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
// 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
|
||||
const ledCount = _getCssTestLedCount();
|
||||
const ledInput = document.getElementById('css-test-led-input');
|
||||
ledInput.value = ledCount;
|
||||
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
const fpsVal = _getCssTestFps();
|
||||
const fpsInput = document.getElementById('css-test-fps-input');
|
||||
fpsInput.value = fpsVal;
|
||||
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||
|
||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||
}
|
||||
}
|
||||
|
||||
function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
@@ -2714,6 +2748,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
||||
// Standard format: raw RGB
|
||||
_cssTestLatestRgb = raw;
|
||||
}
|
||||
|
||||
// Track FPS for api_input sources
|
||||
if (_cssTestIsApiInput) {
|
||||
_cssTestFpsTimestamps.push(performance.now());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2805,12 +2844,14 @@ export function applyCssTestSettings() {
|
||||
_cssTestMeta = null;
|
||||
_cssTestLayerData = null;
|
||||
|
||||
// In CSPT mode, read selected input source
|
||||
if (_cssTestCSPTMode) {
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && inputSel.value) {
|
||||
_cssTestSourceId = inputSel.value;
|
||||
}
|
||||
// Read selected input source from selector (both CSS and CSPT modes)
|
||||
const inputSel = document.getElementById('css-test-cspt-input-select');
|
||||
if (inputSel && 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)
|
||||
@@ -3162,6 +3203,60 @@ export function fireCssTestNotificationLayer(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() {
|
||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||
@@ -3171,6 +3266,10 @@ export function closeTestCssSourceModal() {
|
||||
_cssTestIsComposite = false;
|
||||
_cssTestLayerData = null;
|
||||
_cssTestNotificationIds = [];
|
||||
_cssTestIsApiInput = false;
|
||||
_cssTestStopFpsSampling();
|
||||
_cssTestFpsTimestamps = [];
|
||||
_cssTestFpsActualHistory = [];
|
||||
// Revoke blob URL for frame preview
|
||||
const screen = document.getElementById('css-test-rect-screen');
|
||||
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
||||
|
||||
@@ -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 { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { getBaseOrigin } from './settings.js';
|
||||
|
||||
let _deviceTagsInput = null;
|
||||
let _settingsCsptEntitySelect = null;
|
||||
@@ -366,9 +367,11 @@ export async function showSettings(deviceId) {
|
||||
const wsUrlGroup = document.getElementById('settings-ws-url-group');
|
||||
if (wsUrlGroup) {
|
||||
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 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;
|
||||
wsUrlGroup.style.display = '';
|
||||
} else {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
automationsCacheObj, csptCache,
|
||||
} from '../core/state.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 { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
|
||||
import { showTypePicker } from '../core/icon-select.js';
|
||||
@@ -691,6 +692,7 @@ function _renderGraph(container) {
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
_initResizeClamp(container);
|
||||
_initNodeDrag(nodeGroup, edgeGroup);
|
||||
_initNodeHoverTooltip(nodeGroup, container);
|
||||
_initPortDrag(svgEl, nodeGroup, edgeGroup);
|
||||
_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())
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (_initialized && _nodeMap) {
|
||||
|
||||
@@ -243,6 +243,7 @@ export async function saveScenePreset() {
|
||||
|
||||
scenePresetModal.forceClose();
|
||||
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.recaptured'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast(t('scenes.deleted'), 'success');
|
||||
scenePresetsCache.invalidate();
|
||||
_reloadScenesTab();
|
||||
} else {
|
||||
showToast(t('scenes.error.delete_failed'), 'error');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings — backup / restore configuration.
|
||||
* Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
|
||||
*/
|
||||
|
||||
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 { 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 ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
@@ -114,9 +178,6 @@ export function clearLogViewer() {
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
export function applyLogFilter() {
|
||||
// We don't buffer all raw lines in JS — just clear and note the filter
|
||||
// will apply to future lines. Existing lines that were already rendered
|
||||
// are re-evaluated by toggling their visibility.
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
@@ -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)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
let _logFilterIconSelect = null;
|
||||
let _logLevelIconSelect = null;
|
||||
|
||||
const _LOG_LEVEL_ITEMS = [
|
||||
{ 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: '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: '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') },
|
||||
];
|
||||
/** 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: '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: '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') },
|
||||
];
|
||||
}
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
|
||||
// Reset to first tab
|
||||
switchSettingsTab('general');
|
||||
|
||||
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
|
||||
if (!_logLevelIconSelect) {
|
||||
const levelSel = document.getElementById('settings-log-level');
|
||||
if (levelSel) {
|
||||
_logLevelIconSelect = new IconSelect({
|
||||
target: levelSel,
|
||||
items: _LOG_LEVEL_ITEMS,
|
||||
items: _getLogLevelItems(),
|
||||
columns: 3,
|
||||
onChange: () => setLogLevel(),
|
||||
});
|
||||
@@ -179,6 +274,7 @@ export function openSettingsModal() {
|
||||
}
|
||||
|
||||
loadApiKeysList();
|
||||
loadExternalUrl();
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
loadMqttSettings();
|
||||
@@ -186,7 +282,6 @@ export function openSettingsModal() {
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
disconnectLogViewer();
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -710,6 +710,7 @@ export async function saveTemplate() {
|
||||
|
||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||
templateModal.forceClose();
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('templates.deleted'), 'success');
|
||||
captureTemplatesCache.invalidate();
|
||||
await loadCaptureTemplates();
|
||||
} catch (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');
|
||||
audioTemplateModal.forceClose();
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('audio_template.deleted'), 'success');
|
||||
audioTemplatesCache.invalidate();
|
||||
await loadAudioTemplates();
|
||||
} catch (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) {
|
||||
_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');
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
if (resp.ok) {
|
||||
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 => {
|
||||
const label = d.name;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
@@ -1078,10 +1089,11 @@ export function startAudioTemplateTest() {
|
||||
const [devIdx, devLoop] = deviceVal.split(':');
|
||||
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-stats').style.display = '';
|
||||
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');
|
||||
statusEl.textContent = t('audio_source.test.connecting');
|
||||
@@ -1140,6 +1152,9 @@ function _tplCleanupTest() {
|
||||
_tplTestWs = null;
|
||||
}
|
||||
_tplTestLatest = null;
|
||||
// Re-enable device picker
|
||||
const devSel = document.getElementById('test-audio-template-device');
|
||||
if (devSel) devSel.disabled = false;
|
||||
}
|
||||
|
||||
function _tplSizeCanvas(canvas) {
|
||||
@@ -1279,7 +1294,8 @@ const _streamSectionMap = {
|
||||
proc_templates: [csProcTemplates],
|
||||
css_processing: [csCSPTemplates],
|
||||
color_strip: [csColorStrips],
|
||||
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
|
||||
audio: [csAudioMulti, csAudioMono],
|
||||
audio_templates: [csAudioTemplates],
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
};
|
||||
@@ -1486,6 +1502,7 @@ function renderPictureSourcesList(streams) {
|
||||
{ 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: '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: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
];
|
||||
@@ -1493,37 +1510,44 @@ function renderPictureSourcesList(streams) {
|
||||
// Build tree navigation structure
|
||||
const treeGroups = [
|
||||
{
|
||||
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
|
||||
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
|
||||
children: [
|
||||
{ key: 'raw', titleKey: 'streams.group.raw', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
|
||||
{ key: 'raw_templates', titleKey: 'streams.group.raw_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image',
|
||||
count: staticImageStreams.length,
|
||||
},
|
||||
{
|
||||
key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video',
|
||||
count: videoStreams.length,
|
||||
},
|
||||
{
|
||||
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
|
||||
children: [
|
||||
{ key: 'processed', titleKey: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
|
||||
{ key: 'proc_templates', titleKey: 'streams.group.proc_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
|
||||
{
|
||||
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
|
||||
children: [
|
||||
{ key: 'raw', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
|
||||
{ key: 'raw_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static',
|
||||
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: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
|
||||
children: [
|
||||
{ key: 'processed', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('processed'), count: processedStreams.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',
|
||||
children: [
|
||||
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length },
|
||||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
|
||||
count: _cachedAudioSources.length + _cachedAudioTemplates.length,
|
||||
key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio',
|
||||
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',
|
||||
@@ -1555,7 +1579,7 @@ function renderPictureSourcesList(streams) {
|
||||
const loopback = src.is_loopback !== false;
|
||||
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 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}`;
|
||||
}
|
||||
|
||||
@@ -1647,7 +1671,8 @@ function renderPictureSourcesList(streams) {
|
||||
proc_templates: _cachedPPTemplates.length,
|
||||
css_processing: csptTemplates.length,
|
||||
color_strip: colorStrips.length,
|
||||
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
|
||||
audio: _cachedAudioSources.length,
|
||||
audio_templates: _cachedAudioTemplates.length,
|
||||
value: _cachedValueSources.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 === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||||
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 === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
@@ -1695,7 +1721,8 @@ function renderPictureSourcesList(streams) {
|
||||
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
||||
'css-proc-templates': 'css_processing',
|
||||
'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',
|
||||
'sync-clocks': 'sync',
|
||||
});
|
||||
@@ -2089,6 +2116,7 @@ export async function saveStream() {
|
||||
|
||||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||||
streamModal.forceClose();
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('streams.deleted'), 'success');
|
||||
streamsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
@@ -2675,6 +2704,7 @@ export async function savePPTemplate() {
|
||||
|
||||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||
ppTemplateModal.forceClose();
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('postprocessing.deleted'), 'success');
|
||||
ppTemplatesCache.invalidate();
|
||||
await loadPPTemplates();
|
||||
} catch (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');
|
||||
csptModal.forceClose();
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (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');
|
||||
}
|
||||
showToast(t('css_processing.deleted'), 'success');
|
||||
csptCache.invalidate();
|
||||
await loadCSPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting CSPT:', error);
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function saveSyncClock() {
|
||||
}
|
||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||
syncClockModal.forceClose();
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
syncClocksCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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 { t } from '../core/i18n.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');
|
||||
valueSourceModal.forceClose();
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('value_source.deleted'), 'success');
|
||||
valueSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -312,6 +312,16 @@
|
||||
"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",
|
||||
"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.capture.title": "Capture Settings",
|
||||
"settings.capture.saved": "Capture settings updated",
|
||||
@@ -443,6 +453,7 @@
|
||||
"streams.group.css_processing": "Processing Templates",
|
||||
"streams.group.color_strip": "Color Strips",
|
||||
"streams.group.audio": "Audio",
|
||||
"streams.group.audio_templates": "Audio Templates",
|
||||
"streams.section.streams": "Sources",
|
||||
"streams.add": "Add Source",
|
||||
"streams.add.raw": "Add Screen Capture",
|
||||
@@ -1076,6 +1087,7 @@
|
||||
"color_strip.test.error": "Failed to connect to preview stream",
|
||||
"color_strip.test.led_count": "LEDs:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Receive FPS",
|
||||
"color_strip.test.apply": "Apply",
|
||||
"color_strip.test.composite": "Composite",
|
||||
"color_strip.preview.title": "Live Preview",
|
||||
@@ -1108,7 +1120,7 @@
|
||||
"color_strip.type.processed": "Processed",
|
||||
"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.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.template": "Processing Template:",
|
||||
"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.screen": "Screen",
|
||||
"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.brightness": "Brightness",
|
||||
"color_strip.composite.brightness.none": "None (full brightness)",
|
||||
@@ -1269,11 +1283,20 @@
|
||||
"audio_template.error.delete": "Failed to delete audio template",
|
||||
"streams.group.value": "Value Sources",
|
||||
"streams.group.sync": "Sync Clocks",
|
||||
"tree.group.picture": "Picture Source",
|
||||
"tree.group.capture": "Screen Capture",
|
||||
"tree.group.static": "Static",
|
||||
"tree.group.processing": "Processed",
|
||||
"tree.group.picture": "Picture",
|
||||
"tree.group.strip": "Color Strip",
|
||||
"tree.group.audio": "Audio",
|
||||
"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.select_type": "Select Value Source Type",
|
||||
"value_source.add": "Add Value Source",
|
||||
@@ -1649,6 +1672,9 @@
|
||||
"graph.help.drag_port_desc": "Connect entities",
|
||||
"graph.help.right_click": "Right-click edge",
|
||||
"graph.help.right_click_desc": "Detach connection",
|
||||
"graph.tooltip.fps": "FPS",
|
||||
"graph.tooltip.errors": "Errors",
|
||||
"graph.tooltip.uptime": "Uptime",
|
||||
"automation.enabled": "Automation enabled",
|
||||
"automation.disabled": "Automation disabled",
|
||||
"scene_preset.activated": "Preset activated",
|
||||
|
||||
@@ -312,6 +312,16 @@
|
||||
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||
"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.capture.title": "Настройки Захвата",
|
||||
"settings.capture.saved": "Настройки захвата обновлены",
|
||||
@@ -443,6 +453,7 @@
|
||||
"streams.group.css_processing": "Шаблоны Обработки",
|
||||
"streams.group.color_strip": "Цветовые Полосы",
|
||||
"streams.group.audio": "Аудио",
|
||||
"streams.group.audio_templates": "Аудио шаблоны",
|
||||
"streams.section.streams": "Источники",
|
||||
"streams.add": "Добавить Источник",
|
||||
"streams.add.raw": "Добавить Захват Экрана",
|
||||
@@ -1076,6 +1087,7 @@
|
||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||
"color_strip.test.led_count": "Кол-во LED:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "Частота приёма",
|
||||
"color_strip.test.apply": "Применить",
|
||||
"color_strip.test.composite": "Композит",
|
||||
"color_strip.preview.title": "Предпросмотр",
|
||||
@@ -1108,7 +1120,7 @@
|
||||
"color_strip.type.processed": "Обработанный",
|
||||
"color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику",
|
||||
"color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.",
|
||||
"color_strip.processed.input": "Входной источник:",
|
||||
"color_strip.processed.input": "Источник:",
|
||||
"color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан",
|
||||
"color_strip.processed.template": "Шаблон обработки:",
|
||||
"color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника",
|
||||
@@ -1126,6 +1138,8 @@
|
||||
"color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета",
|
||||
"color_strip.composite.blend_mode.screen": "Экран",
|
||||
"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.brightness": "Яркость",
|
||||
"color_strip.composite.brightness.none": "Нет (полная яркость)",
|
||||
@@ -1269,11 +1283,20 @@
|
||||
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
||||
"streams.group.value": "Источники значений",
|
||||
"streams.group.sync": "Часы синхронизации",
|
||||
"tree.group.capture": "Захват Экрана",
|
||||
"tree.group.picture": "Источники изображений",
|
||||
"tree.group.capture": "Захват экрана",
|
||||
"tree.group.static": "Статичные",
|
||||
"tree.group.processing": "Обработанные",
|
||||
"tree.group.picture": "Изображения",
|
||||
"tree.group.strip": "Цветовые Полосы",
|
||||
"tree.group.strip": "Цветовые полосы",
|
||||
"tree.group.audio": "Аудио",
|
||||
"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.select_type": "Выберите тип источника значений",
|
||||
"value_source.add": "Добавить источник значений",
|
||||
@@ -1649,6 +1672,9 @@
|
||||
"graph.help.drag_port_desc": "Соединить сущности",
|
||||
"graph.help.right_click": "ПКМ по связи",
|
||||
"graph.help.right_click_desc": "Отсоединить связь",
|
||||
"graph.tooltip.fps": "FPS",
|
||||
"graph.tooltip.errors": "Ошибки",
|
||||
"graph.tooltip.uptime": "Время работы",
|
||||
"automation.enabled": "Автоматизация включена",
|
||||
"automation.disabled": "Автоматизация выключена",
|
||||
"scene_preset.activated": "Пресет активирован",
|
||||
|
||||
@@ -312,6 +312,16 @@
|
||||
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
|
||||
"device.tip.add": "点击此处添加新的 LED 设备",
|
||||
"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.capture.title": "采集设置",
|
||||
"settings.capture.saved": "采集设置已更新",
|
||||
@@ -443,6 +453,7 @@
|
||||
"streams.group.css_processing": "处理模板",
|
||||
"streams.group.color_strip": "色带源",
|
||||
"streams.group.audio": "音频",
|
||||
"streams.group.audio_templates": "音频模板",
|
||||
"streams.section.streams": "源",
|
||||
"streams.add": "添加源",
|
||||
"streams.add.raw": "添加屏幕采集",
|
||||
@@ -1076,6 +1087,7 @@
|
||||
"color_strip.test.error": "无法连接到预览流",
|
||||
"color_strip.test.led_count": "LED数量:",
|
||||
"color_strip.test.fps": "FPS:",
|
||||
"color_strip.test.receive_fps": "接收帧率",
|
||||
"color_strip.test.apply": "应用",
|
||||
"color_strip.test.composite": "合成",
|
||||
"color_strip.preview.title": "实时预览",
|
||||
@@ -1108,7 +1120,7 @@
|
||||
"color_strip.type.processed": "已处理",
|
||||
"color_strip.type.processed.desc": "将处理模板应用于另一个源",
|
||||
"color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。",
|
||||
"color_strip.processed.input": "输入源:",
|
||||
"color_strip.processed.input": "源:",
|
||||
"color_strip.processed.input.hint": "将被处理的色带源",
|
||||
"color_strip.processed.template": "处理模板:",
|
||||
"color_strip.processed.template.hint": "应用于输入源输出的滤镜链",
|
||||
@@ -1126,6 +1138,8 @@
|
||||
"color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗",
|
||||
"color_strip.composite.blend_mode.screen": "滤色",
|
||||
"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.brightness": "亮度",
|
||||
"color_strip.composite.brightness.none": "无(全亮度)",
|
||||
@@ -1269,11 +1283,20 @@
|
||||
"audio_template.error.delete": "删除音频模板失败",
|
||||
"streams.group.value": "值源",
|
||||
"streams.group.sync": "同步时钟",
|
||||
"tree.group.picture": "图片源",
|
||||
"tree.group.capture": "屏幕采集",
|
||||
"tree.group.static": "静态",
|
||||
"tree.group.processing": "已处理",
|
||||
"tree.group.picture": "图片",
|
||||
"tree.group.strip": "色带",
|
||||
"tree.group.audio": "音频",
|
||||
"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.select_type": "选择值源类型",
|
||||
"value_source.add": "添加值源",
|
||||
@@ -1649,6 +1672,9 @@
|
||||
"graph.help.drag_port_desc": "连接实体",
|
||||
"graph.help.right_click": "右键边线",
|
||||
"graph.help.right_click_desc": "断开连接",
|
||||
"graph.tooltip.fps": "帧率",
|
||||
"graph.tooltip.errors": "错误",
|
||||
"graph.tooltip.uptime": "运行时间",
|
||||
"automation.enabled": "自动化已启用",
|
||||
"automation.disabled": "自动化已禁用",
|
||||
"scene_preset.activated": "预设已激活",
|
||||
|
||||
@@ -227,7 +227,7 @@ class ColorStripSource:
|
||||
return ApiInputColorStripSource(
|
||||
id=sid, name=name, source_type="api_input",
|
||||
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,
|
||||
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
|
||||
buffers the latest frame and serves it to targets. When no data has been
|
||||
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]
|
||||
timeout: float = 5.0 # seconds before reverting to fallback
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["led_count"] = self.led_count
|
||||
d["fallback_color"] = list(self.fallback_color)
|
||||
d["timeout"] = self.timeout
|
||||
return d
|
||||
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||
created_at: datetime, updated_at: datetime,
|
||||
description=None, clock_id=None, tags=None,
|
||||
led_count=0, fallback_color=None, timeout=None,
|
||||
fallback_color=None, timeout=None,
|
||||
**_kwargs):
|
||||
fb = _validate_rgb(fallback_color, [0, 0, 0])
|
||||
return cls(
|
||||
id=id, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
|
||||
self.fallback_color = fallback_color
|
||||
if kwargs.get("timeout") is not None:
|
||||
self.timeout = float(kwargs["timeout"])
|
||||
if kwargs.get("led_count") is not None:
|
||||
self.led_count = kwargs["led_count"]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -567,7 +567,7 @@
|
||||
<div id="css-editor-processed-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -1,219 +1,234 @@
|
||||
<!-- Settings Modal -->
|
||||
<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">
|
||||
<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">✕</button>
|
||||
</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">
|
||||
<!-- API Keys section (read-only) -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.api_keys.label">API Keys</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
<!-- ═══ General tab ═══ -->
|
||||
<div id="settings-panel-general" class="settings-panel active">
|
||||
<!-- API Keys section (read-only) -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.api_keys.label">API Keys</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
|
||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
|
||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.backup.label">Backup Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
<!-- 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>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
|
||||
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Restore section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.restore.label">Restore Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
|
||||
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
|
||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Partial Export/Import section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.partial.label">Partial Export / Import</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<select id="settings-partial-store" style="flex:1">
|
||||
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
|
||||
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
|
||||
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
|
||||
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
|
||||
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
|
||||
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
|
||||
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
|
||||
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
|
||||
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
|
||||
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
|
||||
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
|
||||
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
|
||||
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
|
||||
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
|
||||
<!-- 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>
|
||||
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="settings-partial-merge">
|
||||
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
|
||||
<!-- 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>
|
||||
|
||||
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Auto-Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="auto-backup-enabled">
|
||||
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
|
||||
<!-- ═══ Backup tab ═══ -->
|
||||
<div id="settings-panel-backup" class="settings-panel">
|
||||
<!-- Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.backup.label">Backup Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
|
||||
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
|
||||
<select id="auto-backup-interval" style="width:100%">
|
||||
<option value="1">1h</option>
|
||||
<option value="6">6h</option>
|
||||
<option value="12">12h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="48">48h</option>
|
||||
<option value="168">7d</option>
|
||||
<!-- Restore section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.restore.label">Restore Configuration</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
|
||||
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
|
||||
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
|
||||
</div>
|
||||
|
||||
<!-- Partial Export/Import section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.partial.label">Partial Export / Import</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<select id="settings-partial-store" style="flex:1">
|
||||
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
|
||||
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
|
||||
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
|
||||
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
|
||||
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
|
||||
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
|
||||
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
|
||||
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
|
||||
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
|
||||
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
|
||||
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
|
||||
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
|
||||
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
|
||||
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
|
||||
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="settings-partial-merge">
|
||||
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
|
||||
</div>
|
||||
|
||||
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
|
||||
<!-- Auto-Backup section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
|
||||
|
||||
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="auto-backup-enabled">
|
||||
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
|
||||
<select id="auto-backup-interval" style="width:100%">
|
||||
<option value="1">1h</option>
|
||||
<option value="6">6h</option>
|
||||
<option value="12">12h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="48">48h</option>
|
||||
<option value="168">7d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
|
||||
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
|
||||
|
||||
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Backups section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Saved Backups section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- MQTT section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="mqtt-enabled">
|
||||
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
|
||||
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
|
||||
<!-- ═══ MQTT tab ═══ -->
|
||||
<div id="settings-panel-mqtt" class="settings-panel">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
|
||||
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="mqtt-enabled">
|
||||
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
|
||||
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
|
||||
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
|
||||
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
|
||||
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
|
||||
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
|
||||
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
|
||||
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
|
||||
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
|
||||
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
|
||||
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
|
||||
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
|
||||
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||
</div>
|
||||
|
||||
<!-- Server Logs section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.logs.label">Server Logs</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
|
||||
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
|
||||
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
|
||||
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
|
||||
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
|
||||
<option value="all" data-i18n="settings.logs.filter.all">All</option>
|
||||
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
|
||||
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
|
||||
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<pre id="log-viewer-output" class="log-viewer-output"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Log Level section -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="settings.log_level.label">Log Level</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
|
||||
<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 id="settings-error" class="error-message" style="display:none;"></div>
|
||||
@@ -223,3 +238,20 @@
|
||||
</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">✕</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>
|
||||
|
||||
@@ -46,12 +46,12 @@
|
||||
|
||||
<!-- CSPT test: input source selector (hidden by default) -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -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">✓</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user