feat(ui): release notes overlay v2 + settings/streams/dashboard polish

Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
  UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
  chip strip + close/external action buttons; opts out of layout.css's
  global `header { height: 60px }` and `header::before` accent bar that
  were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
  asset match (exact basename, then same-extension token-overlap), with
  per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
  /portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
  apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).

Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.

Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.

Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
  source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.

Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
  doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
  the defaults test (was relying on isolation it never enforced).

Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
This commit is contained in:
2026-04-29 17:14:05 +03:00
parent 51eebf21d5
commit 9d4a534ec6
51 changed files with 8574 additions and 1674 deletions
+2
View File
@@ -95,3 +95,5 @@ tmp/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Added by code-review-graph
.code-review-graph/
+39
View File
@@ -104,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
- Follow existing code style and patterns - Follow existing code style and patterns
- Update documentation when changing behavior - Update documentation when changing behavior
- Never make commits or pushes without explicit user approval - Never make commits or pushes without explicit user approval
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
File diff suppressed because it is too large Load Diff
@@ -476,13 +476,16 @@ async def test_color_strip_ws(
meta["layer_infos"] = layer_infos meta["layer_infos"] = layer_infos
await websocket.send_text(_json.dumps(meta)) await websocket.send_text(_json.dumps(meta))
# For api_input: send the current buffer immediately so the client # For api_input: only send an initial frame if a client has actually
# gets a frame right away (fallback color if inactive) rather than # pushed data (push_generation > 0). Without prior data, the preview
# leaving the canvas blank/stale until external data arrives. # stays blank instead of showing the fallback buffer as a stray frame.
if is_api_input: if is_api_input:
initial_colors = stream.get_latest_colors() initial_gen = stream.push_generation
if initial_colors is not None: if initial_gen > 0:
await websocket.send_bytes(initial_colors.tobytes()) _last_push_gen = initial_gen
initial_colors = stream.get_latest_colors()
if initial_colors is not None:
await websocket.send_bytes(initial_colors.tobytes())
# For picture sources, grab the live stream for frame preview # For picture sources, grab the live stream for frame preview
_frame_live = None _frame_live = None
+9
View File
@@ -3,6 +3,14 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class UpdateAssetInfo(BaseModel):
"""A downloadable asset attached to a release (e.g. an installer)."""
name: str
size: int
download_url: str
class UpdateReleaseInfo(BaseModel): class UpdateReleaseInfo(BaseModel):
version: str version: str
tag: str tag: str
@@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel):
body: str body: str
prerelease: bool prerelease: bool
published_at: str published_at: str
assets: list[UpdateAssetInfo] = Field(default_factory=list)
class UpdateStatusResponse(BaseModel): class UpdateStatusResponse(BaseModel):
@@ -45,6 +45,19 @@ class ColorStripStream(ABC):
def target_fps(self) -> int: def target_fps(self) -> int:
"""Target processing rate.""" """Target processing rate."""
@property
def actual_fps(self) -> Optional[float]:
"""Measured rate of *new* frames the stream is delivering, or ``None``.
Only streams backed by an external capture (screen, audio device, API
push) implement this — the value answers "is the upstream actually
keeping up?". Synthetic streams (gradient/static/cycle/effect/...)
always tick at their `target_fps` by construction, so reporting an
actual rate would just duplicate `target_fps` without diagnostic
value; they keep the default ``None``.
"""
return None
@property @property
@abstractmethod @abstractmethod
def led_count(self) -> int: def led_count(self) -> int:
@@ -2,6 +2,7 @@
import threading import threading
import time import time
from collections import deque
from typing import Optional from typing import Optional
import numpy as np import numpy as np
@@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream):
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {} self._last_timing: dict = {}
# Rolling 1s window of timestamps for *new* frames received from
# the live stream. `len(...)` is the per-second frame rate the
# picture pipeline is actually consuming — diverges from
# `target_fps` when the underlying screen capture stalls (heavy
# GPU load, occluded window, DXGI desktop switch, etc.). Reads
# from another thread see a stale length at worst; deque ops are
# atomic under the GIL so no lock is needed.
self._new_frame_timestamps: deque[float] = deque(maxlen=180)
@property @property
def live_stream(self): def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket).""" """Public accessor for the underlying LiveStream (used by preview WebSocket)."""
@@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream):
def target_fps(self) -> int: def target_fps(self) -> int:
return self._fps return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Measured new-frame rate over the last 1 second.
Returns the count of distinct frames the picture loop accepted in
the trailing 1s window. ``None`` until the loop has run (no
meaningful number to report yet).
"""
ts_dq = self._new_frame_timestamps
if not ts_dq:
return None
# Stale-tolerant read: producer may pop while we iterate, but we
# only look at the snapshot length and the leftmost timestamp.
now = time.perf_counter()
# If the stream has gone idle (no new frames for >1s) the deque
# still holds samples until the loop next ticks; report 0 so the
# spark drops to the floor instead of pinning at the last rate.
try:
oldest = ts_dq[0]
except IndexError:
return None
if now - oldest > 1.5:
return 0.0
return float(len(ts_dq))
@property @property
def led_count(self) -> int: def led_count(self) -> int:
return self._led_count return self._led_count
@@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None self._thread = None
self._latest_colors = None self._latest_colors = None
self._previous_colors = None self._previous_colors = None
self._new_frame_timestamps.clear()
logger.info("PictureColorStripStream stopped") logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]: def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream):
cached_frame = frame cached_frame = frame
t0 = time.perf_counter() t0 = time.perf_counter()
# Record the new frame in the rolling 1s window
# used by `actual_fps`. Pop entries older than
# 1s so `len()` reads as frames-per-second.
ts_dq = self._new_frame_timestamps
ts_dq.append(t0)
cutoff = t0 - 1.0
while ts_dq and ts_dq[0] < cutoff:
ts_dq.popleft()
calibration = self._calibration calibration = self._calibration
mapper = self._pixel_mapper mapper = self._pixel_mapper
@@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream):
def target_fps(self) -> int: def target_fps(self) -> int:
return self._fps return self._fps
@property
def actual_fps(self) -> Optional[float]:
"""Aggregate measured capture rate across capture-backed sub-streams.
Sums `actual_fps` from each sub-stream that reports one (i.e.
capture-backed layers like screen/audio captures). Returns
``None`` when no sub-stream measures capture — keeps synthetic-
only composites out of the "Total Capture FPS" cell instead of
contributing a 0.
"""
with self._sub_lock:
subs = list(self._sub_streams.values())
total = 0.0
any_reporting = False
for _src_id, _consumer_id, stream in subs:
try:
v = getattr(stream, "actual_fps", None)
except Exception:
v = None
if isinstance(v, (int, float)):
total += float(v)
any_reporting = True
return total if any_reporting else None
def set_capture_fps(self, fps: int) -> None: def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps)) self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps self._frame_time = 1.0 / self._fps
@@ -75,6 +75,16 @@ class MetricsHistory:
self._system: deque = deque(maxlen=MAX_SAMPLES) self._system: deque = deque(maxlen=MAX_SAMPLES)
self._targets: Dict[str, deque] = {} self._targets: Dict[str, deque] = {}
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
# Baselines for converting cumulative `errors_count` /
# `frames_skipped` into per-second rates inside the system ring
# buffer. None until the first sample arrives so we don't
# synthesize a fake initial spike from "0 → live count".
self._prev_total_errors: Optional[int] = None
self._prev_total_skipped: Optional[int] = None
# Same shape, but for the network throughput counter. Reset to
# None when the cumulative sum drops (target stopped, counter
# reset) so we never emit a negative rate.
self._prev_total_bytes_sent: Optional[int] = None
async def start(self): async def start(self):
"""Start the background sampling loop.""" """Start the background sampling loop."""
@@ -110,7 +120,6 @@ class MetricsHistory:
"""Collect one snapshot of system and target metrics.""" """Collect one snapshot of system and target metrics."""
# System metrics (blocking psutil/nvml calls in thread pool) # System metrics (blocking psutil/nvml calls in thread pool)
sys_snap = await asyncio.to_thread(_collect_system_snapshot) sys_snap = await asyncio.to_thread(_collect_system_snapshot)
self._system.append(sys_snap)
# Per-target metrics from processor states # Per-target metrics from processor states
try: try:
@@ -121,22 +130,151 @@ class MetricsHistory:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
active_ids = set() active_ids = set()
# Aggregates across running targets — mirrors the dashboard's
# frontend computation so the FPS / Capture FPS / Errors cells
# can seed their sparklines from this ring buffer and survive
# a page reload, the same way CPU / RAM already do.
total_fps = 0.0
total_capture_fps = 0.0
total_capture_fps_actual = 0.0
capture_actual_count = 0
total_fps_target = 0.0
total_errors_count = 0
total_frames_skipped = 0
running_count = 0
# Network / send-timing aggregates across running targets.
# `send_timing_*` reads "is the LED transport keeping up?" — a
# leading indicator of network congestion that fires before
# frames actually start dropping.
total_bytes_sent = 0
send_timing_max_ms = 0.0
send_timing_sum_ms = 0.0
send_timing_count = 0
for target_id, state in all_states.items(): for target_id, state in all_states.items():
active_ids.add(target_id) active_ids.add(target_id)
if target_id not in self._targets: if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES) self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
if state.get("processing"): if state.get("processing"):
running_count += 1
fps_actual = state.get("fps_actual")
if isinstance(fps_actual, (int, float)) and fps_actual > 0:
total_fps += float(fps_actual)
fps_capture = state.get("fps_capture")
if isinstance(fps_capture, (int, float)) and fps_capture > 0:
total_capture_fps += float(fps_capture)
fps_capture_actual = state.get("fps_capture_actual")
# `None` means the stream type doesn't measure capture
# (synthetic streams). Counted separately so the cell
# can read "0 of 0" vs "0 of N stalled".
if isinstance(fps_capture_actual, (int, float)):
total_capture_fps_actual += float(fps_capture_actual)
capture_actual_count += 1
fps_target = state.get("fps_target")
if isinstance(fps_target, (int, float)) and fps_target > 0:
total_fps_target += float(fps_target)
errors_count = state.get("errors_count")
if isinstance(errors_count, (int, float)) and errors_count > 0:
total_errors_count += int(errors_count)
frames_skipped = state.get("frames_skipped")
if isinstance(frames_skipped, (int, float)) and frames_skipped > 0:
total_frames_skipped += int(frames_skipped)
bytes_sent = state.get("bytes_sent")
if isinstance(bytes_sent, (int, float)) and bytes_sent > 0:
total_bytes_sent += int(bytes_sent)
send_timing = state.get("timing_send_ms")
if isinstance(send_timing, (int, float)) and send_timing >= 0:
send_timing_sum_ms += float(send_timing)
send_timing_count += 1
if send_timing > send_timing_max_ms:
send_timing_max_ms = float(send_timing)
self._targets[target_id].append( self._targets[target_id].append(
{ {
"t": now, "t": now,
"fps": state.get("fps_actual"), "fps": fps_actual,
"fps_current": state.get("fps_current"), "fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"), "fps_target": fps_target,
"timing": state.get("timing_total_ms"), "timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0), "errors": state.get("errors_count", 0),
} }
) )
# Convert the cumulative error/skipped totals into per-second
# rates. Guard against the first sample (no previous baseline)
# and against counter resets when a target stops or restarts
# (delta < 0 → treat as 0).
errors_per_sec = 0.0
skipped_per_sec = 0.0
bytes_per_sec = 0.0
if self._prev_total_errors is not None:
delta = max(0, total_errors_count - self._prev_total_errors)
errors_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_skipped is not None:
delta = max(0, total_frames_skipped - self._prev_total_skipped)
skipped_per_sec = delta / SAMPLE_INTERVAL
if self._prev_total_bytes_sent is not None:
delta_b = max(0, total_bytes_sent - self._prev_total_bytes_sent)
bytes_per_sec = delta_b / SAMPLE_INTERVAL
self._prev_total_errors = total_errors_count
self._prev_total_skipped = total_frames_skipped
self._prev_total_bytes_sent = total_bytes_sent
# Device latency aggregates — pulled from the manager's
# device-health view rather than re-deriving from per-target
# state, so devices that are shared by multiple targets only
# count once.
device_latency_avg_ms: Optional[float] = None
device_latency_max_ms: Optional[float] = None
device_online_count = 0
device_total_count = 0
try:
health_dicts = self._manager.get_all_device_health_dicts()
except Exception as e:
logger.error("Failed to get device health: %s", e)
health_dicts = {}
latency_sum = 0.0
latency_n = 0
latency_max = 0.0
for _did, h in health_dicts.items():
device_total_count += 1
if h.get("device_online"):
device_online_count += 1
lat = h.get("device_latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
latency_sum += float(lat)
latency_n += 1
if lat > latency_max:
latency_max = float(lat)
if latency_n > 0:
device_latency_avg_ms = round(latency_sum / latency_n, 1)
device_latency_max_ms = round(latency_max, 1)
sys_snap["total_fps"] = round(total_fps, 1)
sys_snap["total_capture_fps"] = round(total_capture_fps, 1)
sys_snap["total_capture_fps_actual"] = round(total_capture_fps_actual, 1)
sys_snap["capture_actual_count"] = capture_actual_count
sys_snap["total_fps_target"] = round(total_fps_target, 1)
sys_snap["total_errors_count"] = total_errors_count
sys_snap["total_frames_skipped"] = total_frames_skipped
sys_snap["errors_per_sec"] = round(errors_per_sec, 3)
sys_snap["skipped_per_sec"] = round(skipped_per_sec, 3)
sys_snap["running_count"] = running_count
sys_snap["total_bytes_sent"] = total_bytes_sent
sys_snap["bytes_per_sec"] = round(bytes_per_sec, 1)
sys_snap["send_timing_avg_ms"] = (
round(send_timing_sum_ms / send_timing_count, 2) if send_timing_count > 0 else 0.0
)
sys_snap["send_timing_max_ms"] = round(send_timing_max_ms, 2)
sys_snap["send_timing_count"] = send_timing_count
sys_snap["device_latency_avg_ms"] = device_latency_avg_ms
sys_snap["device_latency_max_ms"] = device_latency_max_ms
sys_snap["device_online_count"] = device_online_count
sys_snap["device_total_count"] = device_total_count
self._system.append(sys_snap)
# Prune deques for targets no longer registered # Prune deques for targets no longer registered
for tid in list(self._targets.keys()): for tid in list(self._targets.keys()):
if tid not in active_ids: if tid not in active_ids:
@@ -69,6 +69,13 @@ class ProcessingMetrics:
# Streaming liveness (HTTP probe during DDP) # Streaming liveness (HTTP probe during DDP)
device_streaming_reachable: Optional[bool] = None device_streaming_reachable: Optional[bool] = None
fps_effective: int = 0 fps_effective: int = 0
# Cumulative LED-payload bytes sent to the device. Aggregated across
# all running targets in MetricsHistory to derive a per-second
# network throughput sparkline. Counts the color-array payload only;
# protocol overhead (DDP/UDP/IP headers) is sub-5 % for any
# non-trivial LED count and is intentionally ignored to keep the
# counter cheap (`np.ndarray.nbytes`, no per-frame allocation).
bytes_sent: int = 0
@dataclass @dataclass
@@ -400,9 +400,13 @@ class WledTargetProcessor(TargetProcessor):
css_timing: dict = {} css_timing: dict = {}
css_capture_fps: Optional[int] = None css_capture_fps: Optional[int] = None
css_capture_fps_actual: Optional[float] = None
if self._is_running and self._css_stream is not None: if self._is_running and self._css_stream is not None:
css_timing = self._css_stream.get_last_timing() css_timing = self._css_stream.get_last_timing()
css_capture_fps = getattr(self._css_stream, "target_fps", None) css_capture_fps = getattr(self._css_stream, "target_fps", None)
# `actual_fps` is None for synthetic streams (gradient/static/...)
# — only picture/audio/api-input style streams measure it.
css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None)
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
# Picture source timing # Picture source timing
@@ -447,6 +451,8 @@ class WledTargetProcessor(TargetProcessor):
"fps_potential": metrics.fps_potential if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": fps_target, "fps_target": fps_target,
"fps_capture": css_capture_fps, "fps_capture": css_capture_fps,
"fps_capture_actual": css_capture_fps_actual,
"bytes_sent": metrics.bytes_sent if self._is_running else None,
"frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None,
@@ -666,6 +672,8 @@ class WledTargetProcessor(TargetProcessor):
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
else: else:
await self._led_client.send_pixels(send_colors) await self._led_client.send_pixels(send_colors)
# Approximate network throughput counter (LED-payload bytes only).
self._metrics.bytes_sent += int(send_colors.nbytes)
return (time.perf_counter() - t_start) * 1000 return (time.perf_counter() - t_start) * 1000
@staticmethod @staticmethod
@@ -588,6 +588,14 @@ class UpdateService:
"body": rel.body, "body": rel.body,
"prerelease": rel.prerelease, "prerelease": rel.prerelease,
"published_at": rel.published_at, "published_at": rel.published_at,
"assets": [
{
"name": a.name,
"size": a.size,
"download_url": a.download_url,
}
for a in rel.assets
],
} }
if rel if rel
else None else None
+29 -22
View File
@@ -18,39 +18,45 @@ h1 {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
/* Responsive preset grid — matches the mockup's tight 4-up rhythm
on desktop and gracefully reflows on narrow viewports. */
.ap-grid { .ap-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px; gap: 8px;
margin-top: 6px;
} }
/* ─── Preset card (shared) ─── */ /* ─── Preset card (shared) ─── */
.ap-card { .ap-card {
--ap-ch: var(--ch-magenta, #ff4ade);
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
gap: 6px; gap: 5px;
padding: 6px; padding: 5px 5px 4px;
border: 2px solid var(--border-color); border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--radius-md); border-radius: var(--lux-r-md, 8px);
background: var(--card-bg); background: var(--lux-bg-1, var(--card-bg));
cursor: pointer; cursor: pointer;
transition: border-color var(--duration-normal) var(--ease-out), transition: border-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-fast) var(--ease-out); transform var(--duration-fast) var(--ease-out);
} }
.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); }
.ap-card:hover { .ap-card:hover {
border-color: var(--text-muted); border-color: color-mix(in srgb, var(--ap-ch) 50%, var(--lux-line, var(--border-color)));
transform: translateY(-1px); transform: translateY(-1px);
} }
.ap-card.active { .ap-card.active {
border-color: var(--primary-color); border: 2px solid var(--ap-ch);
box-shadow: 0 0 0 1px var(--primary-color), padding: 4px 4px 3px;
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent),
0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
} }
.ap-card.active::after { .ap-card.active::after {
@@ -60,29 +66,30 @@ h1 {
right: 6px; right: 6px;
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--ap-ch);
} }
.ap-card-label { .ap-card-label {
font-size: 0.72rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--lux-ink-dim, var(--text-secondary));
text-align: center; text-align: center;
line-height: 1.2; line-height: 1.2;
letter-spacing: 0.02em;
} }
.ap-card.active .ap-card-label { .ap-card.active .ap-card-label {
color: var(--primary-color); color: var(--ap-ch);
} }
/* ─── Style preset preview ─── */ /* ─── Style preset preview ─── */
.ap-card-preview { .ap-card-preview {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 1 / 1;
border-radius: var(--radius-sm); border-radius: var(--lux-r-sm, 4px);
border: 1px solid; border: 1px solid;
padding: 8px 7px 6px; padding: 7px 6px 5px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -113,12 +120,12 @@ h1 {
.ap-bg-preview { .ap-bg-preview {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 1 / 1;
border-radius: var(--radius-sm); border-radius: var(--lux-r-sm, 4px);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--lux-line, var(--border-color));
} }
.ap-bg-preview-inner { .ap-bg-preview-inner {
+13 -38
View File
@@ -1,48 +1,23 @@
/* ===== AUTOMATIONS ===== */ /* ===== AUTOMATIONS ===== */
.badge-automation-active {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
color: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
}
.badge-automation-inactive {
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-dim, var(--text-color));
}
.badge-automation-disabled {
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.8;
}
.automation-status-disabled { .automation-status-disabled {
opacity: 0.6; opacity: 0.6;
} }
.automation-logic-label { /* Chain-arrow separator — slips between chips on the AUTO card to
font-size: 0.7rem; render the rule flow visually (rule + rule → scene ↩ revert).
Used inside .mod-chips, channel-tinted via the parent's --ch. */
.mod-card .chain-arrow {
display: inline-flex;
align-items: center;
color: var(--ch);
opacity: 0.65;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
font-weight: 600; font-weight: 600;
color: var(--text-muted); letter-spacing: 0.04em;
padding: 0 4px; user-select: none;
} flex-shrink: 0;
/* Automation rule pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
[data-automation-id] .stream-card-prop {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* Automation rule editor rows */ /* Automation rule editor rows */
+25
View File
@@ -2294,6 +2294,31 @@ ul.section-tip li {
height: 100%; height: 100%;
} }
/* Corner tag overlay on a preview surface (e.g. "BUILT-IN" on built-in
gradient strips). Sits in the top-right with a backdrop-blurred dark
pill so it stays legible on any gradient — light, dark, or mid-tone. */
.mod-preview__tag {
position: absolute;
top: 5px;
right: 5px;
padding: 2px 7px;
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
line-height: 1.4;
color: rgba(255, 255, 255, 0.95);
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--lux-r-sm, 3px);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: none;
user-select: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
/* ── Description text ──────────────────────────────────────────── */ /* ── Description text ──────────────────────────────────────────── */
.mod-desc { .mod-desc {
@@ -94,6 +94,19 @@
background: var(--lux-bg-3, var(--border-color)); background: var(--lux-bg-3, var(--border-color));
} }
/* Transparent hairline variant — used for low-emphasis actions like
"Revert" inside the per-section save bar. */
.btn-ghost {
background: transparent;
color: var(--lux-ink-dim, var(--text-color));
border-color: var(--lux-line, var(--border-color));
}
.btn-ghost:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
color: var(--lux-ink, var(--text-color));
border-color: var(--lux-line-bold, var(--border-color));
}
.btn-icon { .btn-icon {
min-width: auto; min-width: auto;
padding: 7px 10px; padding: 7px 10px;
@@ -430,7 +430,7 @@ html:has(#tab-graph.active) {
} }
.graph-node-body { .graph-node-body {
fill: var(--card-bg); fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--lux-line, var(--border-color)); stroke: var(--lux-line, var(--border-color));
stroke-width: 1; stroke-width: 1;
rx: 6; rx: 6;
@@ -723,7 +723,7 @@ html:has(#tab-graph.active) {
} }
.graph-node-overlay-bg { .graph-node-overlay-bg {
fill: var(--card-bg); fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--border-color); stroke: var(--border-color);
stroke-width: 1; stroke-width: 1;
rx: 6; rx: 6;
+7 -6
View File
@@ -382,26 +382,27 @@ h2 {
font-family: var(--font-mono, 'Orbitron', sans-serif); font-family: var(--font-mono, 'Orbitron', sans-serif);
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 600; font-weight: 600;
color: var(--lux-ink-mute, var(--text-secondary)); color: var(--ch-signal, var(--primary-color));
background: transparent; background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
padding: 2px 6px; padding: 2px 6px;
border-radius: 2px; border-radius: 2px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
transition: background 0.3s, color 0.3s, box-shadow 0.3s; transition: background 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s;
} }
#server-version.has-update { #server-version.has-update {
background: var(--warning-color); background: var(--ch-signal, var(--primary-color));
border-color: var(--ch-signal, var(--primary-color));
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
animation: updatePulse 2s ease-in-out infinite; animation: updatePulse 2s ease-in-out infinite;
} }
@keyframes updatePulse { @keyframes updatePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); } 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); }
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
} }
/* ── Update banner ── */ /* ── Update banner ── */
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@
.template-card { .template-card {
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
background: var(--card-bg); background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md)); border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px; padding: 18px 20px 16px;
+5 -3
View File
@@ -106,7 +106,7 @@ import {
} from './features/integrations.ts'; } from './features/integrations.ts';
import { import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset, activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget, addSceneTarget,
} from './features/scene-presets.ts'; } from './features/scene-presets.ts';
@@ -224,7 +224,7 @@ import {
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
loadShutdownAction, setShutdownAction, loadShutdownAction, setShutdownAction,
requestNotifPermissionFromSettings, testNotifFromSettings, requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, getBaseOrigin, loadExternalUrl, saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts'; } from './features/settings.ts';
import { import {
loadUpdateStatus, initUpdateListener, checkForUpdates, loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -416,7 +416,7 @@ Object.assign(window, {
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
// scene presets (modal buttons stay on window; card actions migrated to event delegation) // scene presets modal buttons + mod-card inline handlers
openScenePresetCapture, openScenePresetCapture,
editScenePreset, editScenePreset,
saveScenePreset, saveScenePreset,
@@ -424,6 +424,7 @@ Object.assign(window, {
activateScenePreset, activateScenePreset,
cloneScenePreset, cloneScenePreset,
deleteScenePreset, deleteScenePreset,
recaptureScenePreset,
addSceneTarget, addSceneTarget,
// integrations // integrations
@@ -630,6 +631,7 @@ Object.assign(window, {
requestNotifPermissionFromSettings, requestNotifPermissionFromSettings,
testNotifFromSettings, testNotifFromSettings,
saveExternalUrl, saveExternalUrl,
revertExternalUrl,
getBaseOrigin, getBaseOrigin,
// update // update
+139 -14
View File
@@ -16,14 +16,136 @@ export function desktopFocus(el: HTMLElement | null) {
if (el && !isTouchDevice()) el.focus(); if (el && !isTouchDevice()) el.focus();
} }
export function toggleHint(btn: HTMLElement) { /* Hint popover
const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null; The legacy implementation toggled the inline `<small class="input-hint">`
if (hint && hint.classList.contains('input-hint')) { between display:none and display:block. That worked but pushed every
const visible = hint.style.display !== 'none'; field below it down every help click reflowed half the modal. The
hint.style.display = visible ? 'none' : 'block'; popover variant anchors a floating tooltip to the `?` button so the
btn.classList.toggle('active', !visible); form layout stays stable. The inline `<small>` is kept in the DOM
btn.setAttribute('aria-expanded', String(!visible)); purely as a translation source: data-i18n still binds to it, and we
read its textContent at click time. */
let _hintPopoverEl: HTMLElement | null = null;
let _hintAnchorBtn: HTMLElement | null = null;
let _hintDismissBound = false;
function _ensureHintPopover(): HTMLElement {
if (_hintPopoverEl && document.body.contains(_hintPopoverEl)) return _hintPopoverEl;
const el = document.createElement('div');
el.className = 'hint-popover';
el.setAttribute('role', 'tooltip');
el.setAttribute('aria-hidden', 'true');
document.body.appendChild(el);
_hintPopoverEl = el;
return el;
}
function _closeHintPopover() {
if (_hintPopoverEl) {
_hintPopoverEl.classList.remove('open');
_hintPopoverEl.setAttribute('aria-hidden', 'true');
} }
if (_hintAnchorBtn) {
_hintAnchorBtn.classList.remove('active');
_hintAnchorBtn.setAttribute('aria-expanded', 'false');
_hintAnchorBtn = null;
}
}
function _positionHintPopover(pop: HTMLElement, anchor: HTMLElement) {
// Park at viewport origin and measure natural size while still hidden.
pop.style.left = '0px';
pop.style.top = '0px';
const popRect = pop.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
const gap = 8;
const spaceBelow = window.innerHeight - anchorRect.bottom;
const placeAbove = spaceBelow < popRect.height + gap && anchorRect.top > popRect.height + gap;
const top = placeAbove
? anchorRect.top - popRect.height - gap
: anchorRect.bottom + gap;
let left = anchorRect.left + anchorRect.width / 2 - popRect.width / 2;
left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.dataset.placement = placeAbove ? 'top' : 'bottom';
const rawArrowX = anchorRect.left + anchorRect.width / 2 - left;
const arrowX = Math.max(14, Math.min(rawArrowX, popRect.width - 14));
pop.style.setProperty('--hint-arrow-x', `${arrowX}px`);
}
function _bindHintDismiss() {
if (_hintDismissBound) return;
_hintDismissBound = true;
document.addEventListener('mousedown', (e) => {
if (!_hintAnchorBtn) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (_hintAnchorBtn === target || _hintAnchorBtn.contains(target)) return;
if (_hintPopoverEl && _hintPopoverEl.contains(target)) return;
_closeHintPopover();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _hintAnchorBtn) {
e.stopPropagation();
_closeHintPopover();
}
});
// Reposition or close on layout shifts.
document.addEventListener('scroll', () => _closeHintPopover(), true);
window.addEventListener('resize', () => _closeHintPopover());
document.addEventListener('languageChanged', () => _closeHintPopover());
// Catch the case where a modal closes programmatically (Save button,
// success path) — the modal grows the .closing class which kicks off
// the fadeOut animation. Dismiss any anchored popover at the same time
// so we don't leave an orphaned tooltip floating over the page.
document.addEventListener('animationstart', (e) => {
if (!_hintAnchorBtn) return;
const target = e.target as HTMLElement | null;
if (target?.classList?.contains('modal') && target.classList.contains('closing')) {
_closeHintPopover();
}
}, true);
}
export function toggleHint(btn: HTMLElement) {
// Look for the .input-hint either as the immediate next sibling of the
// .label-row (form-group pattern) or anywhere else inside the
// surrounding .ds-toggle-text / .form-group wrapper. This keeps the
// helper working when the hint sits between the label-row and a
// status sub-line (e.g. OS Permission row in the settings modal).
const labelRow = btn.closest('.label-row') as HTMLElement | null;
let hint = labelRow?.nextElementSibling as HTMLElement | null;
if (!hint || !hint.classList.contains('input-hint')) {
const wrap = btn.closest('.ds-toggle-text, .form-group, .ds-toggle-row') as HTMLElement | null;
hint = wrap?.querySelector(':scope > .input-hint') as HTMLElement | null;
}
if (!hint || !hint.classList.contains('input-hint')) return;
// Force the legacy inline <small> to stay collapsed — the popover
// is now the sole visible surface for hints.
hint.style.display = 'none';
if (_hintAnchorBtn === btn) {
_closeHintPopover();
return;
}
const text = (hint.textContent || '').trim();
if (!text) return;
// Reuse a single popover so we don't pile up tooltip nodes.
_bindHintDismiss();
if (_hintAnchorBtn) _closeHintPopover();
const pop = _ensureHintPopover();
pop.textContent = text;
pop.setAttribute('aria-hidden', 'false');
_positionHintPopover(pop, btn);
pop.classList.add('open');
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
_hintAnchorBtn = btn;
} }
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -433,12 +555,15 @@ export function formatCompact(n: number | null | undefined) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
} }
export function formatUptime(seconds: number | null | undefined) { export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-'; if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600); const total = Math.floor(seconds);
const m = Math.floor((seconds % 3600) / 60); const d = Math.floor(total / 86400);
const s = Math.floor(seconds % 60); const h = Math.floor((total % 86400) / 3600);
if (h > 0) return t('time.hours_minutes', { h, m }); const m = Math.floor((total % 3600) / 60);
if (m > 0) return t('time.minutes_seconds', { m, s }); const s = total % 60;
return t('time.seconds', { s }); const pad = (n: number) => String(n).padStart(2, '0');
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
} }
@@ -436,15 +436,19 @@ export function initAppearance(): void {
} }
} }
/** Render the Appearance tab content. Called when the tab is switched to. */ /** Render the Appearance tab content. Called when the tab is switched to.
* Each preset grid lives in its own channel-coded .ds-section so the panel
* matches the rest of the redesigned settings modal. The .ds-section-meta
* pill shows the active preset name in uppercase. */
export function renderAppearanceTab(): void { export function renderAppearanceTab(): void {
const panel = document.getElementById('settings-panel-appearance'); const panel = document.getElementById('settings-panel-appearance');
if (!panel) return; if (!panel) return;
// Don't re-render if already populated // Don't re-render if already populated — just refresh selections + meta pills
if (panel.querySelector('.appearance-presets')) { if (panel.querySelector('.ds-section')) {
_updatePresetSelection('style', _activeStyleId); _updatePresetSelection('style', _activeStyleId);
_updatePresetSelection('bg', _activeBgEffectId); _updatePresetSelection('bg', _activeBgEffectId);
_updateAppearanceMetaPills();
return; return;
} }
@@ -476,18 +480,46 @@ export function renderAppearanceTab(): void {
}).join(''); }).join('');
panel.innerHTML = ` panel.innerHTML = `
<div class="appearance-presets"> <section class="ds-section" data-ch="magenta">
<div class="form-group"> <div class="ds-section-header">
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label> <span class="ds-section-dot" aria-hidden="true"></span>
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small> <span class="ds-section-title" data-i18n="appearance.style.label">${t('appearance.style.label')}</span>
<span class="ds-section-meta" id="appearance-style-meta"></span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<small class="input-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
<div class="ap-grid">${styleHtml}</div> <div class="ap-grid">${styleHtml}</div>
</div> </div>
<div class="form-group" style="margin-top:1rem"> </section>
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label> <section class="ds-section" data-ch="cyan">
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small> <div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="appearance.bg.label">${t('appearance.bg.label')}</span>
<span class="ds-section-meta" id="appearance-bg-meta"></span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<small class="input-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
<div class="ap-grid">${bgHtml}</div> <div class="ap-grid">${bgHtml}</div>
</div> </div>
</div>`; </section>`;
_updateAppearanceMetaPills();
}
/** Refresh the .ds-section-meta pills to match the active preset names. */
function _updateAppearanceMetaPills(): void {
const styleMeta = document.getElementById('appearance-style-meta');
if (styleMeta) {
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
styleMeta.textContent = preset ? t(preset.nameKey).toUpperCase() : '';
}
const bgMeta = document.getElementById('appearance-bg-meta');
if (bgMeta) {
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
bgMeta.textContent = effect ? t(effect.nameKey).toUpperCase() : '';
}
} }
/** Return the currently active style preset ID. */ /** Return the currently active style preset ID. */
@@ -549,12 +581,13 @@ function _ensureFont(url: string, id: string): void {
document.head.appendChild(link); document.head.appendChild(link);
} }
/** Update the visual selection ring on preset cards. */ /** Update the visual selection ring on preset cards and the meta pill. */
function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void { function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void {
const attr = type === 'style' ? 'style' : 'bg'; const attr = type === 'style' ? 'style' : 'bg';
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => { document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId); el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
}); });
_updateAppearanceMetaPills();
} }
// ─── Listen for theme changes to reapply preset colors ────── // ─── Listen for theme changes to reapply preset colors ──────
+40 -29
View File
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts'; import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts'; import { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts'; import type { Asset } from '../types.ts';
@@ -136,39 +137,49 @@ function getAssetTypeLabel(assetType: string): string {
// ── Card builder ── // ── Card builder ──
export function createAssetCard(asset: Asset): string { export function createAssetCard(asset: Asset): string {
const icon = getAssetTypeIcon(asset.asset_type);
const sizeStr = formatFileSize(asset.size_bytes); const sizeStr = formatFileSize(asset.size_bytes);
const prebuiltBadge = asset.prebuilt const typeLabel = getAssetTypeLabel(asset.asset_type);
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
: '';
let playBtn = ''; const badgeText = `ASSET · ${asset.asset_type.slice(0, 3).toUpperCase()}`;
if (asset.asset_type === 'sound') { const chips: ModChipOpts[] = [
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`; { icon: getAssetTypeIcon(asset.asset_type), text: typeLabel },
{ icon: _icon(P.fileText), text: sizeStr },
];
if (asset.prebuilt) {
chips.push({ icon: _icon(P.star), text: t('asset.prebuilt'), title: t('asset.prebuilt') });
} }
return wrapCard({ const iconActions: any[] = [];
dataAttr: 'data-id', if (asset.asset_type === 'sound') {
id: asset.id, iconActions.push({ icon: ICON_PLAY_SOUND, onclick: '', title: t('asset.play'), dataAttrs: { 'data-action': 'play' } });
removeOnclick: `deleteAsset('${asset.id}')`, }
removeTitle: t('common.delete'), iconActions.push({ icon: ICON_DOWNLOAD, onclick: '', title: t('asset.download'), dataAttrs: { 'data-action': 'download' } });
content: ` iconActions.push({ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } });
<div class="card-header">
<div class="card-title" title="${escapeHtml(asset.name)}"> const mod: ModCardOpts = {
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span> head: {
</div> badge: { text: badgeText },
</div> name: asset.name,
<div class="stream-card-props"> metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span> leds: ['on'],
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span> menu: {
${prebuiltBadge} hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
</div> deleteOnclick: `deleteAsset('${asset.id}')`,
${renderTagChips(asset.tags)}`, },
actions: ` },
${playBtn} body: {
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button> chips,
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`, },
}); foot: {
patchState: 'idle',
patchLabel: 'READY',
iconActions,
},
};
const cardHtml = wrapCard({ dataAttr: 'data-id', id: asset.id, mod });
const tagsHtml = renderTagChips(asset.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Sound playback ── // ── Sound playback ──
@@ -23,6 +23,7 @@ import { ICON_AUDIO_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { FilterListManager } from '../core/filter-list.ts'; import { FilterListManager } from '../core/filter-list.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts } from '../core/mod-card.ts';
import { loadPictureSources } from './streams.ts'; import { loadPictureSources } from './streams.ts';
// ── Module state ───────────────────────────────────────────── // ── Module state ─────────────────────────────────────────────
@@ -266,34 +267,44 @@ export function renderAPTModalFilterList() { aptFilterManager.render(); }
// ── Card rendering (used by streams.ts) ─────────────────────── // ── Card rendering (used by streams.ts) ───────────────────────
export function createAudioProcessingTemplateCard(tmpl: any): string { export function createAudioProcessingTemplateCard(tmpl: any): string {
let filterChainHtml = ''; const filters = tmpl.filters || [];
if (tmpl.filters && tmpl.filters.length > 0) { const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
const filterNames = tmpl.filters.map((fi: any) => { filters.map((fi: any, idx: number) => {
let label = _getAudioFilterName(fi.filter_id); let label = _getAudioFilterName(fi.filter_id);
if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) { if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) {
const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id); const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`; if (ref) label += `: ${ref.name}`;
} }
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`; const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
}); return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`; }).join('')
} }</div>` : '';
return wrapCard({ const mod: ModCardOpts = {
type: 'template-card', head: {
dataAttr: 'data-apt-id', badge: { text: 'TPL \u00b7 AUDIO PROC' },
id: tmpl.id, name: tmpl.name,
removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`, metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
removeTitle: t('common.delete'), leds: ['off'],
content: ` menu: {
<div class="template-card-header"> duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}</div> hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`,
</div> deleteOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''} },
${filterChainHtml} },
${renderTagChips(tmpl.tags)}`, body: {
actions: ` desc: tmpl.description || undefined,
<button class="btn btn-icon btn-secondary" onclick="cloneAudioProcessingTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> extraHtml: chainExtra || undefined,
<button class="btn btn-icon btn-secondary" onclick="editAudioProcessingTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, },
}); foot: {
patchState: 'idle',
patchLabel: 'PIPELINE',
iconActions: [
{ icon: ICON_EDIT, onclick: `editAudioProcessingTemplate('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-apt-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
@@ -11,9 +11,10 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts'; import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts'; import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
@@ -257,96 +258,180 @@ function renderAutomations(automations: any, sceneMap: any) {
} }
} }
type RulePillRenderer = (c: any) => string; type RuleChipBuilder = (c: any) => ModChipOpts;
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = { /* Build one chip per automation rule. The chip shows the rule type's
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`, icon + a tight, scannable label. Mirrors the AUTO card in the
cards-redesign demo: rules read as a left-to-right chain leading into
the scene activation. */
const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
application: (c) => { application: (c) => {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ') || '—';
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`; return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
}, },
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`, time_of_day: (c) => ({
icon: ICON_CLOCK,
text: `${c.start_time || '00:00'} ${c.end_time || '23:59'}`,
title: t('automations.rule.time_of_day'),
}),
system_idle: (c) => { system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active'); const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`; return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
}, },
display_state: (c) => { display_state: (c) => {
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on')); const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`; return { icon: ICON_MONITOR, text: stateLabel, title: t('automations.rule.display_state') };
}, },
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`, mqtt: (c) => ({
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`, icon: ICON_RADIO,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`, text: `${c.topic || ''} = ${c.payload || '*'}`,
title: t('automations.rule.mqtt'),
}),
webhook: () => ({ icon: ICON_WEB, text: t('automations.rule.webhook') }),
home_assistant: (c) => ({
icon: _icon(P.home),
text: `${c.entity_id || ''} = ${c.state || '*'}`,
title: t('automations.rule.home_assistant'),
}),
}; };
/** Render a chain-arrow separator span. `+` between AND-rules,
* the localised OR label between OR-rules, and `` for the
* rule-chain scene-activation transition. */
function _chainArrow(glyph: string): string {
return `<span class="chain-arrow" aria-hidden="true">${escapeHtml(glyph)}</span>`;
}
/** Render a single chip as the same markup `renderModChips` produces,
* so the body chain row reads identically to chip arrays elsewhere
* (devices/value sources). Inline build so we can intersperse
* chain-arrow separators between chips. */
function _chipHtml(c: ModChipOpts): string {
const variant = c.variant === 'tag' ? ' chip--tag'
: c.variant === 'err' ? ' chip--err'
: '';
const link = c.onclick ? ' chip--link' : '';
const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : '';
const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : '';
return `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
}
function createAutomationCard(automation: Automation, sceneMap = new Map()) { function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive'; // ── Rule chips: one per rule, joined by chain-arrow separators
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive'); // (`+` for AND, `OR` for OR — mirrors the demo's flow language). ──
const ruleChips: ModChipOpts[] = automation.rules.length
? automation.rules.map(c => {
const builder = RULE_CHIP_RENDERERS[c.rule_type];
return builder ? builder(c) : { text: c.rule_type };
})
: [{ text: t('automations.rules.empty') }];
let rulePills = ''; const logicGlyph = automation.rule_logic === 'and' ? '+' : 'OR';
if (automation.rules.length === 0) { const ruleChain = ruleChips.map(_chipHtml).join(_chainArrow(logicGlyph));
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
} else {
const parts = automation.rules.map(c => {
const renderer = RULE_PILL_RENDERERS[c.rule_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
});
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info // ── Scene chip: the action — clickable, navigates to the scene card. ──
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null; const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected'); const sceneChipHtml = _chipHtml(scene ? {
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888'; icon: ICON_SCENE,
text: scene.name,
title: t('automations.scene'),
onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.scene_preset_id}')`,
variant: 'tag',
} : {
icon: ICON_SCENE,
text: t('automations.scene.none_selected'),
});
// Deactivation mode label // ── Optional deactivation chip — `↩` revert or fallback scene.
let deactivationMeta = ''; // Rendered after the scene chip with a chain arrow so the card
// reads as: rules → scene ↩ (deactivation behaviour). ──
let deactivationHtml = '';
if (automation.deactivation_mode === 'revert') { if (automation.deactivation_mode === 'revert') {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.revert')}</span>`; deactivationHtml = _chainArrow('↩') + _chipHtml({
icon: ICON_UNDO,
text: t('automations.deactivation_mode.revert'),
});
} else if (automation.deactivation_mode === 'fallback_scene') { } else if (automation.deactivation_mode === 'fallback_scene') {
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null; const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
if (fallback) { deactivationHtml = _chainArrow('↩') + _chipHtml(fallback ? {
const fbColor = fallback.color || '#4fc3f7'; icon: ICON_UNDO,
deactivationMeta = `<span class="card-meta stream-card-link" onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.deactivation_scene_preset_id}')">${ICON_UNDO} <span style="color:${fbColor}">&#x25CF;</span> ${escapeHtml(fallback.name)}</span>`; text: fallback.name,
} else { title: t('automations.deactivation_mode.fallback_scene'),
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}</span>`; onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.deactivation_scene_preset_id}')`,
} } : {
icon: ICON_UNDO,
text: t('automations.deactivation_mode.fallback_scene'),
});
} }
let lastActivityMeta = ''; const chipsHtml = `<div class="mod-chips">${ruleChain}${_chainArrow('')}${sceneChipHtml}${deactivationHtml}</div>`;
// ── State surfaces: LED + patch indicator ──
// Active = blink (live signal); Enabled-but-idle = off (waiting);
// Disabled = fault (red, indicates unavailable rather than error).
const ledState = !automation.enabled ? 'fault'
: automation.is_active ? 'blink'
: 'off';
const patchState = !automation.enabled ? 'offline'
: automation.is_active ? 'live'
: 'idle';
const patchLabel = !automation.enabled ? t('automations.status.disabled').toUpperCase()
: automation.is_active ? t('automations.status.active').toUpperCase()
: t('automations.status.inactive').toUpperCase();
// ── Meta: last-fired timestamp only — the rule/scene chain is
// already laid out below, so meta stays a quiet single-line
// history hint. ──
let metaHtml: string | undefined;
if (automation.last_activated_at) { if (automation.last_activated_at) {
const ts = new Date(automation.last_activated_at); const ts = new Date(automation.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`; metaHtml = `${t('automations.last_activated')} · ${escapeHtml(ts.toLocaleString())}`;
} }
return wrapCard({ // ── Badge: "AUTO · XX" — short id slice mirrors the demo's
// "AUTO · 07" pattern (last 2 hex chars, uppercase). ──
const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA';
const mod: ModCardOpts = {
running: automation.is_active,
head: {
badge: { text: `AUTO · ${shortId}` },
name: automation.name,
metaHtml,
leds: [ledState],
menu: {
duplicateOnclick: `cloneAutomation('${automation.id}')`,
hideOnclick: `toggleCardHidden('automations','${automation.id}')`,
deleteOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
},
},
body: {
extraHtml: chipsHtml,
},
foot: {
patchState,
patchLabel,
secondaryActions: [
automation.enabled
? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' }
: { label: t('search.action.enable'), icon: ICON_START, onclick: `toggleAutomationEnabled('${automation.id}', true)`, variant: 'go' },
],
iconActions: [
{ icon: ICON_EDIT, onclick: `openAutomationEditor('${automation.id}')`, title: t('automations.edit') },
],
},
};
const cardHtml = wrapCard({
dataAttr: 'data-automation-id', dataAttr: 'data-automation-id',
id: automation.id, id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '', classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`, mod,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(automation.name)}">
<span class="card-title-text">${escapeHtml(automation.name)}</span>
<span class="badge badge-automation-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${rulePills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
${renderTagChips(automation.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START}
</button>`,
}); });
const tagsHtml = renderTagChips(automation.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
export async function openAutomationEditor(automationId?: any, cloneData?: any) { export async function openAutomationEditor(automationId?: any, cloneData?: any) {
@@ -18,6 +18,7 @@ import {
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE, ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE,
} from '../../core/icons.ts'; } from '../../core/icons.ts';
import { wrapCard } from '../../core/card-colors.ts'; import { wrapCard } from '../../core/card-colors.ts';
import type { ModCardOpts } from '../../core/mod-card.ts';
import type { ColorStripSource } from '../../types.ts'; import type { ColorStripSource } from '../../types.ts';
import { bindableValue, bindableColor } from '../../types.ts'; import { bindableValue, bindableColor } from '../../types.ts';
import { renderTagChips } from '../../core/tag-input.ts'; import { renderTagChips } from '../../core/tag-input.ts';
@@ -273,6 +274,25 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
/* ── Main card builder ────────────────────────────────────────── */ /* ── Main card builder ────────────────────────────────────────── */
const STRIP_BADGE: Record<string, string> = {
static: 'STRIP · COLOR',
gradient: 'STRIP · GRD',
color_cycle: 'STRIP · CYCLE',
effect: 'STRIP · FX',
composite: 'STRIP · COMP',
mapped: 'STRIP · MAP',
audio: 'STRIP · AUDIO',
api_input: 'STRIP · API',
notification: 'STRIP · NOTIF',
daylight: 'STRIP · DAY',
candlelight: 'STRIP · CANDLE',
weather: 'STRIP · WEATHER',
key_colors: 'STRIP · KEY',
math_wave: 'STRIP · WAVE',
processed: 'STRIP · OUT',
picture_advanced: 'STRIP · CALIB',
};
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) { export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
// Clock crosslink badge // Clock crosslink badge
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null; const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
@@ -291,45 +311,54 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap }) ? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
: _renderPictureCardProps(source, pictureSourceMap); : _renderPictureCardProps(source, pictureSourceMap);
const icon = getColorStripIcon(source.source_type); const ledCount = (source as any).led_count || 0;
const badgeText = STRIP_BADGE[source.source_type] || 'STRIP · MAPPED';
const metaText = ledCount ? `${ledCount} px` : (source.source_type || '').replace(/_/g, ' ');
const isNotification = source.source_type === 'notification'; const isNotification = source.source_type === 'notification';
const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type); const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
const calibrationBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
const overlayBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
: '';
const testNotifyBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
: '';
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const isKeyColors = source.source_type === 'key_colors'; const isKeyColors = source.source_type === 'key_colors';
const regionsBtn = isKeyColors
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</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({ const iconActions: any[] = [];
dataAttr: 'data-css-id', iconActions.push({ icon: ICON_TEST, onclick: `event.stopPropagation(); testColorStrip('${source.id}')`, title: t('color_strip.test.title') });
id: source.id, if (isPictureKind) {
removeOnclick: `deleteColorStrip('${source.id}')`, const calibrationOnclick = source.source_type === 'picture_advanced'
removeTitle: t('common.delete'), ? `showAdvancedCalibration('${source.id}')`
content: ` : `showCSSCalibration('${source.id}')`;
<div class="card-header"> iconActions.push({ icon: ICON_CALIBRATION, onclick: calibrationOnclick, title: t('calibration.title') });
<div class="card-title" title="${escapeHtml(source.name)}"> iconActions.push({ icon: ICON_OVERLAY, onclick: `event.stopPropagation(); toggleCSSOverlay('${source.id}')`, title: t('overlay.toggle') });
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span> }
</div> if (isKeyColors) {
</div> iconActions.push({ icon: ICON_PATTERN_TEMPLATE, onclick: `event.stopPropagation(); configureKCRegions('${source.id}')`, title: t('color_strip.key_colors.configure_regions') });
<div class="stream-card-props"> }
${propsHtml} if (isNotification) {
</div> iconActions.push({ icon: ICON_BELL, onclick: `event.stopPropagation(); testNotification('${source.id}')`, title: t('color_strip.notification.test') });
${renderTagChips(source.tags)}`, iconActions.push({ icon: ICON_AUTOMATION, onclick: `event.stopPropagation(); showNotificationHistory()`, title: t('color_strip.notification.history.title') });
actions: ` }
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> iconActions.push({ icon: ICON_EDIT, onclick: `showCSSEditor('${source.id}')`, title: t('common.edit') });
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`, const mod: ModCardOpts = {
}); head: {
badge: { text: badgeText },
name: source.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
menu: {
duplicateOnclick: `cloneColorStrip('${source.id}')`,
hideOnclick: `toggleCardHidden('color-strips','${source.id}')`,
deleteOnclick: `deleteColorStrip('${source.id}')`,
},
},
body: {
extraHtml: propsHtml ? `<div class="stream-card-props">${propsHtml}</div>` : undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'STRIP',
iconActions,
},
};
const cardHtml = wrapCard({ dataAttr: 'data-css-id', id: source.id, mod });
const tagsHtml = renderTagChips(source.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
@@ -76,12 +76,16 @@ const PERF_CELL_LABEL_KEYS: Record<string, string> = {
patches: 'dashboard.perf.active_patches', patches: 'dashboard.perf.active_patches',
fps: 'dashboard.perf.total_fps', fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps', capture_fps: 'dashboard.perf.total_capture_fps',
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
errors: 'dashboard.perf.errors', errors: 'dashboard.perf.errors',
devices: 'dashboard.perf.devices', devices: 'dashboard.perf.devices',
cpu: 'dashboard.perf.cpu', cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram', ram: 'dashboard.perf.ram',
gpu: 'dashboard.perf.gpu', gpu: 'dashboard.perf.gpu',
temp: 'dashboard.perf.temp', temp: 'dashboard.perf.temp',
network: 'dashboard.perf.network',
device_latency: 'dashboard.perf.device_latency',
send_timing: 'dashboard.perf.send_timing',
}; };
let _unsubscribe: (() => void) | null = null; let _unsubscribe: (() => void) | null = null;
@@ -36,14 +36,17 @@ export type PerfCellKey =
| 'patches' | 'patches'
| 'fps' | 'fps'
| 'capture_fps' | 'capture_fps'
| 'capture_fps_actual'
| 'errors' | 'errors'
| 'devices' | 'devices'
| 'cpu' | 'cpu'
| 'ram' | 'ram'
| 'gpu' | 'gpu'
| 'temp' | 'temp'
// Reserved.
| 'network' | 'network'
| 'device_latency'
| 'send_timing'
// Reserved.
| 'disk' | 'disk'
| 'audio-peak'; | 'audio-peak';
@@ -145,12 +148,16 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultPerfCell('patches'), _defaultPerfCell('patches'),
_defaultPerfCell('fps'), _defaultPerfCell('fps'),
_defaultPerfCell('capture_fps'), _defaultPerfCell('capture_fps'),
_defaultPerfCell('capture_fps_actual', false),
_defaultPerfCell('errors'), _defaultPerfCell('errors'),
_defaultPerfCell('devices'), _defaultPerfCell('devices'),
_defaultPerfCell('cpu'), _defaultPerfCell('cpu'),
_defaultPerfCell('ram'), _defaultPerfCell('ram'),
_defaultPerfCell('gpu'), _defaultPerfCell('gpu'),
_defaultPerfCell('temp', false), _defaultPerfCell('temp', false),
_defaultPerfCell('network', false),
_defaultPerfCell('device_latency', false),
_defaultPerfCell('send_timing', false),
], ],
global: { global: {
width: 'full', width: 'full',
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalErrors, updateDevices, rerenderPerfGrid } from './perf-charts.ts'; import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts'; import { isActiveTab } from '../core/tab-registry.ts';
import { import {
@@ -39,6 +39,11 @@ let _fpsCurrentHistory: Record<string, number[]> = {};
let _fpsCharts: Record<string, any> = {}; let _fpsCharts: Record<string, any> = {};
let _lastRunningIds: string[] = []; let _lastRunningIds: string[] = [];
let _lastSyncClockIds: string = ''; let _lastSyncClockIds: string = '';
/** Previous cumulative `bytes_sent` summed across running targets.
* Used to convert the WLED transport byte counter into a per-poll
* delta that drives the Network throughput sparkline. `null` until
* the first poll so we don't emit a phantom spike on page load. */
let _prevTotalBytesSent: number | null = null;
let _uptimeBase: Record<string, UptimeBase> = {}; let _uptimeBase: Record<string, UptimeBase> = {};
let _uptimeTimer: ReturnType<typeof setInterval> | null = null; let _uptimeTimer: ReturnType<typeof setInterval> | null = null;
let _uptimeElements: Record<string, Element> = {}; let _uptimeElements: Record<string, Element> = {};
@@ -99,10 +104,6 @@ function _startUptimeTimer(): void {
if (!el) continue; if (!el) continue;
const seconds = _getInterpolatedUptime(id); const seconds = _getInterpolatedUptime(id);
if (seconds != null) { if (seconds != null) {
// Pure text — the .mod-metric "UPTIME" label already
// carries the icon meaning, and dropping it gives the
// value enough room for "4m 32s" / "1h 17m" without
// clipping inside the fixed-width metric cell.
el.textContent = formatUptime(seconds); el.textContent = formatUptime(seconds);
} }
} }
@@ -601,6 +602,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const statesObj = payload.states || {}; const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[]; const deviceStateList = Object.values(statesObj) as any[];
updateDevices(deviceStateList); updateDevices(deviceStateList);
// Device-latency cell — avg / max ping latency across
// online devices. Already deduplicated by device_id
// (this list is keyed on device, not target). Offline
// devices contribute to the total count but not the
// latency aggregate.
let onlineCount = 0;
let latencyMax = 0;
let latencySum = 0;
let latencyN = 0;
for (const ds of deviceStateList) {
if (ds?.device_online) onlineCount++;
const l = ds?.device_latency_ms;
if (typeof l === 'number' && Number.isFinite(l) && l >= 0) {
latencySum += l;
latencyN += 1;
if (l > latencyMax) latencyMax = l;
}
}
const latencyAvg = latencyN > 0 ? latencySum / latencyN : null;
updateDeviceLatency(
latencyAvg,
latencyN > 0 ? latencyMax : null,
onlineCount,
deviceStateList.length,
);
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
} }
@@ -656,6 +683,13 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
let fpsSum = 0; let fpsSum = 0;
let fpsTargetSum = 0; let fpsTargetSum = 0;
let captureFpsSum = 0; let captureFpsSum = 0;
// Capture-actual aggregates: only count targets whose stream
// reports a measured rate (capture-backed, e.g. screen capture).
// Synthetic streams report null and are excluded so the cell
// can read "no captures" instead of "0 fps".
let captureFpsActualSum = 0;
let captureActualReportingCount = 0;
let captureFpsActualTargetSum = 0;
for (const r of running) { for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current : r.state?.fps_current != null ? r.state.fps_current
@@ -673,6 +707,17 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
captureFpsValues.push(captureFps); captureFpsValues.push(captureFps);
captureFpsSum += captureFps; captureFpsSum += captureFps;
} }
const captureFpsActual = r.state?.fps_capture_actual;
if (typeof captureFpsActual === 'number') {
captureFpsActualSum += captureFpsActual;
captureActualReportingCount += 1;
// Use this target's source-side rate as the per-capture
// ceiling so the "% of requested" subtitle matches the
// captures actually being measured.
if (typeof captureFps === 'number' && captureFps > 0) {
captureFpsActualTargetSum += captureFps;
}
}
} }
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null; const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null; const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
@@ -680,6 +725,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null; const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null;
const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null; const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax); updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount);
// Errors / dropped frames — fed cumulative totals; the perf // Errors / dropped frames — fed cumulative totals; the perf
// cell turns them into per-second rates by tracking deltas // cell turns them into per-second rates by tracking deltas
@@ -688,14 +734,44 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// counter elevated forever. // counter elevated forever.
let totalErrors = 0; let totalErrors = 0;
let totalSkipped = 0; let totalSkipped = 0;
let totalBytesSent = 0;
let sendTimingSum = 0;
let sendTimingMax = 0;
let sendTimingCount = 0;
for (const r of running) { for (const r of running) {
const e = r.metrics?.errors_count; const e = r.metrics?.errors_count;
if (typeof e === 'number' && e > 0) totalErrors += e; if (typeof e === 'number' && e > 0) totalErrors += e;
const s = r.state?.frames_skipped; const s = r.state?.frames_skipped;
if (typeof s === 'number' && s > 0) totalSkipped += s; if (typeof s === 'number' && s > 0) totalSkipped += s;
const b = r.state?.bytes_sent;
if (typeof b === 'number' && b > 0) totalBytesSent += b;
const t = r.state?.timing_send_ms;
if (typeof t === 'number' && Number.isFinite(t) && t >= 0) {
sendTimingSum += t;
sendTimingCount += 1;
if (t > sendTimingMax) sendTimingMax = t;
}
} }
updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval); updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval);
// Network throughput — convert cumulative byte counter into
// a per-second rate via deltas, same shape as the errors
// cell. Counter resets (target stop/restart) leave the
// total unchanged or smaller; rate is clamped non-negative
// inside the perf-charts module.
const pollSec = Math.max(0.05, dashboardPollInterval / 1000);
const bytesDelta = _prevTotalBytesSent != null
? Math.max(0, totalBytesSent - _prevTotalBytesSent)
: 0;
const bytesPerSec = _prevTotalBytesSent != null ? bytesDelta / pollSec : 0;
_prevTotalBytesSent = totalBytesSent;
updateNetworkThroughput(bytesPerSec, totalBytesSent);
// Send-timing — already an instantaneous "ms last frame"
// value, so we just average / max across running targets.
const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0;
updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount);
// Check if we can do an in-place metrics update (same targets, not first load) // Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(','); const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(',');
@@ -23,6 +23,30 @@ import type { Device } from '../types.ts';
let _deviceTagsInput: any = null; let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null; let _settingsCsptEntitySelect: any = null;
/* The General Settings modal groups its many conditional fields into
four `.ds-section` panels (Identity / Connection / Hardware / Behavior).
showSettings() toggles individual `.form-group` visibility by device
type and capability this helper then collapses any section whose
form-groups have all ended up `display: none`, so the user never
sees a section header with nothing underneath it. */
function _updateSettingsSectionVisibility() {
const root = document.getElementById('device-settings-modal');
if (!root) return;
const sections = root.querySelectorAll<HTMLElement>('.ds-section');
sections.forEach((sec) => {
if (sec.dataset.dsKey === 'identity') {
sec.dataset.dsEmpty = 'false';
return;
}
const groups = sec.querySelectorAll<HTMLElement>('.form-group');
let anyVisible = false;
groups.forEach((g) => {
if (g.style.display !== 'none') anyVisible = true;
});
sec.dataset.dsEmpty = anyVisible ? 'false' : 'true';
});
}
function _ensureSettingsCsptSelect() { function _ensureSettingsCsptSelect() {
const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
@@ -624,6 +648,7 @@ export async function showSettings(deviceId: any) {
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (csptSel) csptSel.value = device.default_css_processing_template_id || ''; if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
_updateSettingsSectionVisibility();
settingsModal.snapshot(); settingsModal.snapshot();
settingsModal.open(); settingsModal.open();
@@ -4,7 +4,7 @@
*/ */
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts'; import { ICON_HEART, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
// ─── Config ───────────────────────────────────────────────── // ─── Config ─────────────────────────────────────────────────
@@ -61,40 +61,48 @@ export function snoozeDonation(): void {
_hideBanner(); _hideBanner();
} }
/** Render the About panel content in settings modal. */ /** Render the About panel content in settings modal.
* Uses the Lumenworks rack-panel + about-hero pattern from the
* settings-modal-redesign mockup: a channel-coded .ds-section
* wrapping a centered hero with mark, name, version pill, tagline,
* and external-link buttons. */
export function renderAboutPanel(): void { export function renderAboutPanel(): void {
const container = document.getElementById('about-panel-content'); const container = document.getElementById('about-panel-content');
if (!container) return; if (!container) return;
const version = document.getElementById('version-number')?.textContent || ''; const version = document.getElementById('version-number')?.textContent?.trim() || '';
let links = '';
const linkButtons: string[] = [];
if (_repoUrl) { if (_repoUrl) {
links += `<a href="${_repoUrl}" target="_blank" rel="noopener" class="about-link"> linkButtons.push(
${ICON_GITHUB} `<a href="${_repoUrl}" target="_blank" rel="noopener" class="btn">
<span>${t('donation.view_source')}</span> ${ICON_GITHUB}
${ICON_EXTERNAL_LINK} <span>${t('donation.view_source')}</span>
</a>`; </a>`,
);
} }
if (_donateUrl) { if (_donateUrl) {
links += `<a href="${_donateUrl}" target="_blank" rel="noopener" class="about-link about-link-donate"> linkButtons.push(
${ICON_HEART} `<a href="${_donateUrl}" target="_blank" rel="noopener" class="btn">
<span>${t('donation.about_donate')}</span> ${ICON_HEART}
${ICON_EXTERNAL_LINK} <span>${t('donation.about_donate')}</span>
</a>`; </a>`,
);
} }
container.innerHTML = ` container.innerHTML = `
<div class="about-section"> <section class="ds-section" data-ch="amber">
<div class="about-logo">${ICON_HEART}</div> <div class="ds-section-body">
<h3 class="about-title">${t('donation.about_title')}</h3> <div class="about-hero">
${version ? `<span class="about-version">${version}</span>` : ''} <div class="about-mark" aria-hidden="true">L</div>
<p class="about-text">${t('donation.about_opensource')}</p> <div class="about-name">${t('donation.about_title')}</div>
${links ? `<div class="about-links">${links}</div>` : ''} ${version ? `<div class="about-version">${version}</div>` : ''}
<p class="about-license">${t('donation.about_license')}</p> <div class="about-tag">${t('donation.about_opensource')}</div>
</div> ${linkButtons.length ? `<div class="about-links">${linkButtons.join('')}</div>` : ''}
<div class="about-license">${t('donation.about_license')}</div>
</div>
</div>
</section>
`; `;
} }
@@ -12,6 +12,7 @@ import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts'; import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import { import {
@@ -540,34 +541,54 @@ export function testGameConnection() {
// ── Card renderer ── // ── Card renderer ──
export function createGameIntegrationCard(gi: GameIntegration): string { export function createGameIntegrationCard(gi: GameIntegration): string {
const adapterIcon = getGameAdapterIcon(gi.adapter_type);
const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type; const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type;
const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive'); const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
const mappingCount = gi.event_mappings?.length || 0; const mappingCount = gi.event_mappings?.length || 0;
const isEnabled = !!gi.enabled;
return wrapCard({ // Badge: GAME · {ADAPTER} — adapter type compressed to 4-6 chars uppercase
type: 'template-card', const adapterBadge = String(gi.adapter_type).toUpperCase().slice(0, 8);
dataAttr: 'data-gi-id', const badgeText = `GAME · ${adapterBadge}`;
id: gi.id,
removeOnclick: `deleteGameIntegration('${gi.id}')`, const leds: LedState[] = isEnabled ? ['on'] : ['off'];
removeTitle: t('common.delete'),
content: ` const chips: ModChipOpts[] = [
<div class="template-card-header"> { icon: ICON_GAMEPAD, text: adapterName, title: t('game_integration.adapter') },
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div> { icon: ICON_CIRCLE_DOT, text: enabledLabel, title: t('game_integration.status'), variant: isEnabled ? 'tag' : 'default' },
</div> ];
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''} if (mappingCount > 0) {
<div class="stream-card-props"> chips.push({ icon: _icon(P.listChecks), text: `${mappingCount} ${t('game_integration.mappings') || 'mappings'}`, title: t('game_integration.mappings') });
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span> }
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''} const mod: ModCardOpts = {
</div> head: {
${renderTagChips(gi.tags)}`, badge: { text: badgeText },
actions: ` name: gi.name,
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button> metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> leds,
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, menu: {
}); duplicateOnclick: `cloneGameIntegration('${gi.id}')`,
hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`,
deleteOnclick: `deleteGameIntegration('${gi.id}')`,
},
},
body: {
desc: gi.description || undefined,
chips,
},
foot: {
patchState: isEnabled ? 'live' : 'idle',
patchLabel: isEnabled ? 'ARMED' : 'DISARMED',
iconActions: [
{ icon: ICON_TEST, onclick: `event.stopPropagation(); showGameEventMonitor('${gi.id}')`, title: t('game_integration.events.monitor') },
{ icon: ICON_EDIT, onclick: `showGameIntegrationEditor('${gi.id}')`, title: t('common.edit') },
],
},
running: isEnabled,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-gi-id', id: gi.id, mod });
const tagsHtml = renderTagChips(gi.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── CRUD ── // ── CRUD ──
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_REFRESH } from '../core/icons.ts'; import { ICON_CLONE, ICON_EDIT, ICON_REFRESH } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import type { HomeAssistantSource } from '../types.ts'; import type { HomeAssistantSource } from '../types.ts';
@@ -217,44 +218,51 @@ export async function testHASource(): Promise<void> {
// ── Card rendering ── // ── Card rendering ──
export function createHASourceCard(source: HomeAssistantSource) { export function createHASourceCard(source: HomeAssistantSource) {
let healthClass: string, healthTitle: string; const isConnected = !!source.connected;
if (source.connected) { const leds: LedState[] = isConnected ? ['on', 'on'] : ['fault'];
healthClass = 'health-online'; const healthTitle = isConnected
healthTitle = `${t('ha_source.connected')}${source.entity_count} entities`; ? `${t('ha_source.connected')}${source.entity_count} entities`
} else { : t('ha_source.disconnected');
healthClass = 'health-offline';
healthTitle = t('ha_source.disconnected');
}
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
return wrapCard({ const chips: ModChipOpts[] = [
type: 'template-card', { icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, text: source.host, title: source.host },
dataAttr: 'data-id', ];
id: source.id, if (isConnected) {
removeOnclick: `deleteHASource('${source.id}')`, chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`, text: `${source.entity_count} entities` });
removeTitle: t('common.delete'), }
content: ` if (source.use_ssl) {
<div class="template-card-header"> chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`, text: 'SSL' });
<div class="template-name">${ICON_HA} ${statusDot} ${escapeHtml(source.name)}</div> }
</div>
<div class="stream-card-props"> const mod: ModCardOpts = {
<span class="stream-card-prop"> head: {
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.host)} badge: { text: 'HA · BRIDGE' },
</span> name: source.name,
${source.connected ? `<span class="stream-card-prop"> metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`),
<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg> ${source.entity_count} entities leds,
</span>` : ''} menu: {
${source.use_ssl ? `<span class="stream-card-prop"> duplicateOnclick: `cloneHASource('${source.id}')`,
<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg> SSL hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`,
</span>` : ''} deleteOnclick: `deleteHASource('${source.id}')`,
</div> },
${renderTagChips(source.tags)} },
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`, body: {
actions: ` desc: source.description || undefined,
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('ha_source.test')}">${ICON_REFRESH}</button> chips,
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button> },
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`, foot: {
}); patchState: isConnected ? 'live' : 'offline',
patchLabel: isConnected ? 'CONNECTED' : 'OFFLINE',
iconActions: [
{ icon: ICON_REFRESH, onclick: '', title: healthTitle, dataAttrs: { 'data-action': 'test' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
],
},
running: isConnected,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod });
const tagsHtml = renderTagChips(source.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Event delegation ── // ── Event delegation ──
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import type { MQTTSource } from '../types.ts'; import type { MQTTSource } from '../types.ts';
@@ -234,41 +235,44 @@ async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
// ── Card rendering ── // ── Card rendering ──
export function createMQTTSourceCard(source: MQTTSource) { export function createMQTTSourceCard(source: MQTTSource) {
let healthClass: string, healthTitle: string; const isConnected = !!source.connected;
if (source.connected) { const leds: LedState[] = isConnected ? ['on', 'blink'] : ['fault'];
healthClass = 'health-online'; const broker = `${source.broker_host}:${source.broker_port}`;
healthTitle = t('mqtt_source.connected');
} else {
healthClass = 'health-offline';
healthTitle = t('mqtt_source.disconnected');
}
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
return wrapCard({ const chips: ModChipOpts[] = [
type: 'template-card', { icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, text: broker, title: broker },
dataAttr: 'data-id', { icon: `<svg class="icon" viewBox="0 0 24 24">${P.hash}</svg>`, text: source.base_topic, title: source.base_topic },
id: source.id, ];
removeOnclick: `deleteMQTTSource('${source.id}')`,
removeTitle: t('common.delete'), const mod: ModCardOpts = {
content: ` head: {
<div class="template-card-header"> badge: { text: 'MQTT · BROKER' },
<div class="template-name">${ICON_MQTT} ${statusDot} ${escapeHtml(source.name)}</div> name: source.name,
</div> metaHtml: escapeHtml(`${broker} · ${source.base_topic}`),
<div class="stream-card-props"> leds,
<span class="stream-card-prop"> menu: {
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.broker_host)}:${source.broker_port} duplicateOnclick: `cloneMQTTSource('${source.id}')`,
</span> hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`,
<span class="stream-card-prop"> deleteOnclick: `deleteMQTTSource('${source.id}')`,
<svg class="icon" viewBox="0 0 24 24">${P.hash}</svg> ${escapeHtml(source.base_topic)} },
</span> },
</div> body: {
${renderTagChips(source.tags)} desc: source.description || undefined,
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`, chips,
actions: ` },
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('mqtt_source.test')}">${ICON_TEST}</button> foot: {
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button> patchState: isConnected ? 'live' : 'offline',
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`, patchLabel: isConnected ? 'SUBSCRIBED' : 'OFFLINE',
}); iconActions: [
{ icon: ICON_TEST, onclick: '', title: t('mqtt_source.test'), dataAttrs: { 'data-action': 'test' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
],
},
running: isConnected,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod });
const tagsHtml = renderTagChips(source.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Event delegation ── // ── Event delegation ──
@@ -16,17 +16,17 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts'; import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts';
const MAX_SAMPLES = 120; const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'errors'] as const; const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing'] as const;
/** Every cell key the user can color-customize, including the /** Every cell key the user can color-customize, including the
* patches / devices cells that don't have sparklines but still * patches / devices cells that don't have sparklines but still
* carry a header accent stripe. */ * carry a header accent stripe. */
const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const; const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp', 'network', 'device_latency', 'send_timing'] as const;
const PERF_MODE_KEY = 'perfMetricsMode'; const PERF_MODE_KEY = 'perfMetricsMode';
const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio) const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
const SPARK_H = 64; const SPARK_H = 64;
/** Metrics that don't have a per-process variant (host-only). */ /** Metrics that don't have a per-process variant (host-only). */
const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'errors']); const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing']);
/** Default accent per metric maps to channel palette via CSS vars so the /** Default accent per metric maps to channel palette via CSS vars so the
perf cards share the same language as the rest of the app. Overrides perf cards share the same language as the rest of the app. Overrides
@@ -35,12 +35,16 @@ const METRIC_CSS_VARS: Record<string, string> = {
patches: '--ch-magenta', patches: '--ch-magenta',
fps: '--ch-cyan', fps: '--ch-cyan',
capture_fps: '--ch-signal', capture_fps: '--ch-signal',
capture_fps_actual: '--ch-cyan',
errors: '--ch-coral', errors: '--ch-coral',
devices: '--ch-signal', devices: '--ch-signal',
cpu: '--ch-coral', cpu: '--ch-coral',
ram: '--ch-violet', ram: '--ch-violet',
gpu: '--ch-signal', gpu: '--ch-signal',
temp: '--ch-amber', temp: '--ch-amber',
network: '--ch-violet',
device_latency: '--ch-amber',
send_timing: '--ch-magenta',
}; };
/** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */ /** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
@@ -48,19 +52,23 @@ const METRIC_FALLBACK: Record<string, string> = {
patches: '#EC4899', patches: '#EC4899',
fps: '#00D8FF', fps: '#00D8FF',
capture_fps: '#22D3EE', capture_fps: '#22D3EE',
capture_fps_actual: '#00D8FF',
errors: '#FF6B6B', errors: '#FF6B6B',
devices: '#10B981', devices: '#10B981',
cpu: '#FF6B6B', cpu: '#FF6B6B',
ram: '#A855F7', ram: '#A855F7',
gpu: '#10B981', gpu: '#10B981',
temp: '#FCD34D', temp: '#FCD34D',
network: '#A855F7',
device_latency: '#FCD34D',
send_timing: '#EC4899',
}; };
type PerfMode = 'system' | 'app' | 'both'; type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null; let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] }; let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] }; let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] };
/** Peak errors-per-second observed during the session y-axis ceiling /** Peak errors-per-second observed during the session y-axis ceiling
* for the errors sparkline so a single spike doesn't flatten the rest * for the errors sparkline so a single spike doesn't flatten the rest
* of the line. */ * of the line. */
@@ -77,6 +85,19 @@ let _prevSkippedTotal: number | null = null;
let _fpsPeak = 60; let _fpsPeak = 60;
/** Same role as `_fpsPeak`, but for the capture-side sparkline. */ /** Same role as `_fpsPeak`, but for the capture-side sparkline. */
let _captureFpsPeak = 60; let _captureFpsPeak = 60;
/** Same role as `_captureFpsPeak`, but for the *measured* capture
* rate ("Total Capture FPS"). Tracked independently so a slow
* external capture doesn't get its scale clamped to whatever the
* source-side spark already touched. */
let _captureFpsActualPeak = 60;
/** Y-axis ceiling tracker for the network-throughput sparkline.
* Bytes/sec scales freely. Initial floor of 1 KB/s keeps the
* spark from collapsing onto a single pixel during idle periods. */
let _networkPeak = 1024;
/** Y-axis ceilings for the latency / send-timing sparks (ms).
* Floors chosen so common WiFi-good values don't pin the line. */
let _deviceLatencyPeak = 50;
let _sendTimingPeak = 20;
/** Sum of fps_target across running targets rendered as a dashed /** Sum of fps_target across running targets rendered as a dashed
* reference line on the FPS spark ("max achievable throughput"). */ * reference line on the FPS spark ("max achievable throughput"). */
let _fpsTargetSum = 0; let _fpsTargetSum = 0;
@@ -92,7 +113,11 @@ let _lastFetchData: any = null;
let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null; let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null;
let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null; let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null;
let _lastTotalCaptureFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null } | null = null; let _lastTotalCaptureFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null } | null = null;
let _lastTotalCaptureFpsActualArgs: { totalFps: number; targetSum: number; reportingCount: number } | null = null;
let _lastErrorsArgs: { totalErrors: number; totalSkipped: number; pollMs: number } | null = null; let _lastErrorsArgs: { totalErrors: number; totalSkipped: number; pollMs: number } | null = null;
let _lastNetworkArgs: { bytesPerSec: number; totalBytes: number } | null = null;
let _lastDeviceLatencyArgs: { avgMs: number | null; maxMs: number | null; onlineCount: number; totalCount: number } | null = null;
let _lastSendTimingArgs: { avgMs: number; maxMs: number; reportingCount: number } | null = null;
let _lastDevicesArgs: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[] | null = null; let _lastDevicesArgs: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[] | null = null;
/** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy /** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy
* callers that read it directly; sync'd from the layout on every read * callers that read it directly; sync'd from the layout on every read
@@ -166,28 +191,24 @@ export function setPerfMode(mode: PerfMode): void {
_fetchPerformance(); _fetchPerformance();
} }
/** Returns the static HTML for the perf section. */ /** Color-picker widget rendered next to each cell's label. Even
export function renderPerfSection(): string { * cells without sparklines (patches/devices) get one it drives
_syncMode(); * the card's `--perf-accent` CSS var for the header stripe. */
for (const key of ALL_COLORABLE_KEYS) { function _colorWidget(key: string): string {
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); return createColorPicker({
}
/** Color-picker widget rendered next to each cell's label. Even
* cells without sparklines (patches/devices) get one it drives
* the card's `--perf-accent` CSS var for the header stripe. */
const colorWidget = (key: string) => createColorPicker({
id: `perf-${key}`, id: `perf-${key}`,
currentColor: _getColor(key), currentColor: _getColor(key),
onPick: undefined, onPick: undefined,
anchor: 'left', anchor: 'left',
showReset: true, showReset: true,
}); });
}
const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => ` function _sparkCardHtml(key: string, labelKey: string, hiddenByEnv: boolean): string {
return `
<div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}" style="--perf-accent:${_getColor(key)}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}> <div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}" style="--perf-accent:${_getColor(key)}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t(labelKey)} ${colorWidget(key)}</span> <span class="perf-chart-label">${t(labelKey)} ${_colorWidget(key)}</span>
<span class="perf-chart-app" id="perf-${key}-app" aria-hidden="true"></span> <span class="perf-chart-app" id="perf-${key}-app" aria-hidden="true"></span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
@@ -198,11 +219,19 @@ export function renderPerfSection(): string {
<div class="perf-chart-spark" id="perf-chart-${key}"></div> <div class="perf-chart-spark" id="perf-chart-${key}"></div>
</div> </div>
</div>`; </div>`;
}
const patchesCell = ` /** Build HTML for a single perf cell. Returns null for unknown keys.
* Cells with env-gated visibility (gpu, temp) start hidden and reveal
* themselves when the server reports a real reading; user can also
* force them hidden via Customize. */
function _renderCellHtml(key: string): string | null {
switch (key) {
case 'patches':
return `
<div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('patches')}"> <div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('patches')}">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'} ${colorWidget('patches')}</span> <span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'} ${_colorWidget('patches')}</span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
<div class="perf-chart-value-block"> <div class="perf-chart-value-block">
@@ -213,11 +242,11 @@ export function renderPerfSection(): string {
<div class="perf-patches-list" id="perf-patches-list"></div> <div class="perf-patches-list" id="perf-patches-list"></div>
</div> </div>
</div>`; </div>`;
case 'fps':
const fpsCell = ` return `
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('fps')}"> <div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('fps')}">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'} ${colorWidget('fps')}</span> <span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'} ${_colorWidget('fps')}</span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
<div class="perf-chart-value-block"> <div class="perf-chart-value-block">
@@ -227,11 +256,11 @@ export function renderPerfSection(): string {
<div class="perf-chart-spark" id="perf-chart-fps"></div> <div class="perf-chart-spark" id="perf-chart-fps"></div>
</div> </div>
</div>`; </div>`;
case 'capture_fps':
const captureFpsCell = ` return `
<div class="perf-chart-card" data-metric="capture_fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps')}"> <div class="perf-chart-card" data-metric="capture_fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps')}">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.total_capture_fps') || 'Total Capture FPS'} ${colorWidget('capture_fps')}</span> <span class="perf-chart-label">${t('dashboard.perf.total_capture_fps') || 'Total Source FPS'} ${_colorWidget('capture_fps')}</span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
<div class="perf-chart-value-block"> <div class="perf-chart-value-block">
@@ -241,11 +270,25 @@ export function renderPerfSection(): string {
<div class="perf-chart-spark" id="perf-chart-capture_fps"></div> <div class="perf-chart-spark" id="perf-chart-capture_fps"></div>
</div> </div>
</div>`; </div>`;
case 'capture_fps_actual':
const errorsCell = ` return `
<div class="perf-chart-card" data-metric="capture_fps_actual" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps_actual')}" id="perf-capture_fps_actual-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.total_capture_fps_actual') || 'Total Capture FPS'} ${_colorWidget('capture_fps_actual')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-capture_fps_actual-value"></span>
<span class="perf-chart-subtitle" id="perf-capture_fps_actual-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-capture_fps_actual"></div>
</div>
</div>`;
case 'errors':
return `
<div class="perf-chart-card perf-errors-cell" data-metric="errors" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('errors')}"> <div class="perf-chart-card perf-errors-cell" data-metric="errors" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('errors')}">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.errors') || 'Errors'} ${colorWidget('errors')}</span> <span class="perf-chart-label">${t('dashboard.perf.errors') || 'Errors'} ${_colorWidget('errors')}</span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
<div class="perf-chart-value-block"> <div class="perf-chart-value-block">
@@ -255,11 +298,53 @@ export function renderPerfSection(): string {
<div class="perf-chart-spark" id="perf-chart-errors"></div> <div class="perf-chart-spark" id="perf-chart-errors"></div>
</div> </div>
</div>`; </div>`;
case 'network':
const devicesCell = ` return `
<div class="perf-chart-card" data-metric="network" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('network')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.network') || 'Network'} ${_colorWidget('network')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-network-value"></span>
<span class="perf-chart-subtitle" id="perf-network-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-network"></div>
</div>
</div>`;
case 'device_latency':
return `
<div class="perf-chart-card" data-metric="device_latency" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('device_latency')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.device_latency') || 'Device Latency'} ${_colorWidget('device_latency')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-device_latency-value"></span>
<span class="perf-chart-subtitle" id="perf-device_latency-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-device_latency"></div>
</div>
</div>`;
case 'send_timing':
return `
<div class="perf-chart-card" data-metric="send_timing" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('send_timing')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.send_timing') || 'Send Timing'} ${_colorWidget('send_timing')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-send_timing-value"></span>
<span class="perf-chart-subtitle" id="perf-send_timing-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-send_timing"></div>
</div>
</div>`;
case 'devices':
return `
<div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}"> <div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
<div class="perf-chart-header"> <div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'} ${colorWidget('devices')}</span> <span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'} ${_colorWidget('devices')}</span>
</div> </div>
<div class="perf-chart-body"> <div class="perf-chart-body">
<div class="perf-chart-value-block"> <div class="perf-chart-value-block">
@@ -271,28 +356,42 @@ export function renderPerfSection(): string {
<div class="perf-devices-dots" id="perf-devices-dots"></div> <div class="perf-devices-dots" id="perf-devices-dots"></div>
</div> </div>
</div>`; </div>`;
case 'cpu': return _sparkCardHtml('cpu', 'dashboard.perf.cpu', false);
case 'ram': return _sparkCardHtml('ram', 'dashboard.perf.ram', false);
case 'gpu': return _sparkCardHtml('gpu', 'dashboard.perf.gpu', false);
case 'temp': return _sparkCardHtml('temp', 'dashboard.perf.temp', true);
default: return null;
}
}
// Cell registry — what each layout key actually renders. Cells with /** Re-register color-picker callbacks for every colorable cell. Idempotent
// env-gated visibility (gpu, temp) start hidden and reveal themselves * overwrites the previous handler keyed by id. Called whenever the
// when the server reports a real reading; user can also force them * perf section is (re)initialised so newly-created cells get wired up. */
// hidden via Customize. function _registerPerfColorPickers(): void {
const cellRenderers: Record<string, () => string> = { for (const key of ALL_COLORABLE_KEYS) {
patches: () => patchesCell, registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
fps: () => fpsCell, }
capture_fps: () => captureFpsCell, }
errors: () => errorsCell,
devices: () => devicesCell, /** Build a fresh `.perf-chart-card` element from a key. */
cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false), function _buildCellElement(key: string): HTMLElement | null {
ram: () => sparkCard('ram', 'dashboard.perf.ram', false), const html = _renderCellHtml(key);
gpu: () => sparkCard('gpu', 'dashboard.perf.gpu', false), if (!html) return null;
temp: () => sparkCard('temp', 'dashboard.perf.temp', true), const tmp = document.createElement('div');
}; tmp.innerHTML = html.trim();
return tmp.firstElementChild as HTMLElement | null;
}
/** Returns the static HTML for the perf section. */
export function renderPerfSection(): string {
_syncMode();
_registerPerfColorPickers();
let cellsHtml = ''; let cellsHtml = '';
for (const cell of getOrderedPerfCells()) { for (const cell of getOrderedPerfCells()) {
if (!cell.visible) continue; if (!cell.visible) continue;
const render = cellRenderers[cell.key]; const html = _renderCellHtml(cell.key);
if (render) cellsHtml += render(); if (html) cellsHtml += html;
} }
return `<div class="perf-charts-grid">${cellsHtml}</div>`; return `<div class="perf-charts-grid">${cellsHtml}</div>`;
@@ -382,12 +481,12 @@ export function updateTotalFps(
_renderChartSvg('fps', /*animate=*/true); _renderChartSvg('fps', /*animate=*/true);
} }
/** Total Capture FPS cell pushed a new sample each dashboard refresh /** Total Source FPS cell sum of every running target's upstream
* cycle. `totalFps` is the sum of `fps_capture` (configured capture-side * color-strip-source `target_fps` (picture/audio/gradient/effect/...).
* rate) across running targets; `minFps` / `maxFps` are the live * This is the *requested* tick rate of the pipeline feeding LEDs, not
* extremes shown as a subdued subtitle. Mirrors `updateTotalFps` but * the measured throughput of any external capture a static-color
* for the capture side, so multi-stream setups can see how much capture * stream still ticks at its idle rate and contributes here. The
* work is being scheduled. */ * internal key stays `capture_fps` for layout-storage compatibility. */
export function updateTotalCaptureFps( export function updateTotalCaptureFps(
totalFps: number, totalFps: number,
minFps: number | null, minFps: number | null,
@@ -415,6 +514,184 @@ export function updateTotalCaptureFps(
_renderChartSvg('capture_fps', /*animate=*/true); _renderChartSvg('capture_fps', /*animate=*/true);
} }
/** Total Capture FPS cell sum of *measured* new-frame rates from
* capture-backed streams (screen capture today; audio/api-input
* follow-up). Diverges from Total Source FPS when the upstream
* capture stalls Source FPS reads "what was requested," Capture FPS
* reads "what actually arrived." `reportingCount` is how many running
* targets have a measured rate (i.e. are capture-backed); used as the
* subtitle so a synthetic-only setup reads "no captures" instead of
* silently sitting at 0. */
export function updateTotalCaptureFpsActual(
totalFps: number,
targetSum: number,
reportingCount: number,
): void {
_lastTotalCaptureFpsActualArgs = { totalFps, targetSum, reportingCount };
const fps = Math.max(0, totalFps);
_history.capture_fps_actual.push(fps);
if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift();
if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps;
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
: '';
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}
}
const subEl = document.getElementById('perf-capture_fps_actual-sub');
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
// Drop ratio reads "how far behind requested" — useful at-a-glance
// diagnostic for capture saturation.
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
}
}
_renderChartSvg('capture_fps_actual', /*animate=*/true);
}
/** Network throughput cell bytes/sec the LED transport is moving
* across all running targets, plus a cumulative "X total" subtitle.
* Pairs with the Errors cell to triage where a slowdown lives:
* pipeline-side errors with low throughput CPU/GPU bottleneck,
* pipeline running clean with throughput pinned WiFi/wired
* saturated. The bytes counter approximates LED-payload only
* (protocol overhead is sub-5 % for any real LED count). */
export function updateNetworkThroughput(
bytesPerSec: number,
totalBytes: number,
): void {
_lastNetworkArgs = { bytesPerSec, totalBytes };
const bps = Math.max(0, bytesPerSec);
_history.network.push(bps);
if (_history.network.length > MAX_SAMPLES) _history.network.shift();
if (bps > _networkPeak) _networkPeak = bps;
_paintNetworkValue(bps, totalBytes);
_renderChartSvg('network', /*animate=*/true);
}
/** Device-latency cell average ping latency across *online*
* devices, with the worst-offender max as a subtitle. A leading
* indicator of WiFi degradation that fires before frames start
* dropping; pairs with the Devices cell to pinpoint which device
* is misbehaving. */
export function updateDeviceLatency(
avgMs: number | null,
maxMs: number | null,
onlineCount: number,
totalCount: number,
): void {
_lastDeviceLatencyArgs = { avgMs, maxMs, onlineCount, totalCount };
const sample = avgMs != null && Number.isFinite(avgMs) ? Math.max(0, avgMs) : 0;
_history.device_latency.push(sample);
if (_history.device_latency.length > MAX_SAMPLES) _history.device_latency.shift();
if (sample > _deviceLatencyPeak) _deviceLatencyPeak = sample;
_paintDeviceLatencyValue(avgMs, maxMs, onlineCount, totalCount);
_renderChartSvg('device_latency', /*animate=*/true);
}
/** Send-timing cell average and max time spent inside the LED
* client's send call across running targets. Climbs as a
* pre-failure signal when the network gets congested, several
* seconds before the Errors cell starts showing skipped frames. */
export function updateSendTiming(
avgMs: number,
maxMs: number,
reportingCount: number,
): void {
_lastSendTimingArgs = { avgMs, maxMs, reportingCount };
const sample = Math.max(0, avgMs);
_history.send_timing.push(sample);
if (_history.send_timing.length > MAX_SAMPLES) _history.send_timing.shift();
const peakSample = Math.max(sample, Math.max(0, maxMs));
if (peakSample > _sendTimingPeak) _sendTimingPeak = peakSample;
_paintSendTimingValue(avgMs, maxMs, reportingCount);
_renderChartSvg('send_timing', /*animate=*/true);
}
function _formatBytesPerSec(bps: number): { value: string; unit: string } {
if (bps >= 1024 * 1024) return { value: (bps / 1024 / 1024).toFixed(1), unit: 'MB/s' };
if (bps >= 1024) return { value: (bps / 1024).toFixed(1), unit: 'KB/s' };
return { value: bps.toFixed(0), unit: 'B/s' };
}
function _formatBytes(b: number): string {
if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${b} B`;
}
function _paintNetworkValue(bytesPerSec: number, totalBytes: number): void {
const valEl = document.getElementById('perf-network-value');
if (valEl) {
const { value, unit } = _formatBytesPerSec(bytesPerSec);
valEl.innerHTML = `${value}<span class="perf-fps-unit">${unit}</span>`;
}
const subEl = document.getElementById('perf-network-sub');
if (subEl) {
subEl.textContent = totalBytes > 0 ? `${_formatBytes(totalBytes)} total` : '';
}
}
function _paintDeviceLatencyValue(
avgMs: number | null,
maxMs: number | null,
onlineCount: number,
totalCount: number,
): void {
const valEl = document.getElementById('perf-device_latency-value');
if (valEl) {
if (totalCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no devices</span>';
} else if (avgMs == null) {
valEl.innerHTML = '<span class="perf-chart-hint">offline</span>';
} else {
const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
}
}
const subEl = document.getElementById('perf-device_latency-sub');
if (subEl) {
const parts: string[] = [];
if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} online`);
if (maxMs != null) parts.push(`max ${maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0)}ms`);
subEl.textContent = parts.join(' · ');
}
}
function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: number): void {
const valEl = document.getElementById('perf-send_timing-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">idle</span>';
} else {
const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
}
}
const subEl = document.getElementById('perf-send_timing-sub');
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else {
const maxTxt = maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0);
subEl.textContent = `max ${maxTxt}ms · ${reportingCount} target${reportingCount > 1 ? 's' : ''}`;
}
}
}
/** Errors cell converts the cumulative `errors_count` and /** Errors cell converts the cumulative `errors_count` and
* `frames_skipped` totals (summed across running targets) into rates by * `frames_skipped` totals (summed across running targets) into rates by
* taking per-poll deltas. The card stays at "0" / muted accent when * taking per-poll deltas. The card stays at "0" / muted accent when
@@ -601,7 +878,11 @@ function _renderChartSvg(key: string, animate: boolean = false): void {
const yMax = key === 'temp' ? 100 const yMax = key === 'temp' ? 100
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1) : key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1)
: key === 'capture_fps' ? Math.max(60, _captureFpsPeak * 1.1) : key === 'capture_fps' ? Math.max(60, _captureFpsPeak * 1.1)
: key === 'capture_fps_actual' ? Math.max(60, _captureFpsActualPeak * 1.1, _fpsTargetSum * 1.1)
: key === 'errors' ? Math.max(5, _errorsPeak * 1.2) : key === 'errors' ? Math.max(5, _errorsPeak * 1.2)
: key === 'network' ? Math.max(1024, _networkPeak * 1.1)
: key === 'device_latency' ? Math.max(50, _deviceLatencyPeak * 1.2)
: key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2)
: 100; : 100;
const paths: string[] = []; const paths: string[] = [];
@@ -884,6 +1165,12 @@ async function _seedFromServer(): Promise<void> {
// the full /system/performance payload that does include totals. // the full /system/performance payload that does include totals.
_appHistory.gpu = []; _appHistory.gpu = [];
// FPS / Capture FPS / Errors aggregates — populated by the
// server-side ring buffer so these sparks survive page reloads
// (mirrors how CPU / RAM already work). Older payloads without
// these fields produce empty arrays; live polling fills them in.
_seedAggregateHistories(samples);
if (_history.gpu.length > 0) { if (_history.gpu.length > 0) {
_hasGpu = true; _hasGpu = true;
} else if (samples.length > 0) { } else if (samples.length > 0) {
@@ -903,6 +1190,209 @@ async function _seedFromServer(): Promise<void> {
} }
} }
/** Reconstruct the FPS / Capture FPS / Errors history + value labels
* from the server's ring buffer. Each system snapshot since v1.x
* carries the running-target aggregate so we can rehydrate without
* waiting for the dashboard's batch fetch (which is what populated
* these previously and lost everything on every reload). */
function _seedAggregateHistories(samples: any[]): void {
if (samples.length === 0) return;
const fpsSeries = samples
.map((s: any) => s.total_fps)
.filter((v: any) => typeof v === 'number' && Number.isFinite(v));
if (fpsSeries.length > 0) {
_history.fps = fpsSeries.slice(-MAX_SAMPLES);
_fpsPeak = Math.max(60, ..._history.fps);
}
const captureSeries = samples
.map((s: any) => s.total_capture_fps)
.filter((v: any) => typeof v === 'number' && Number.isFinite(v));
if (captureSeries.length > 0) {
_history.capture_fps = captureSeries.slice(-MAX_SAMPLES);
_captureFpsPeak = Math.max(60, ..._history.capture_fps);
}
const captureActualSeries = samples
.map((s: any) => s.total_capture_fps_actual)
.filter((v: any) => typeof v === 'number' && Number.isFinite(v));
if (captureActualSeries.length > 0) {
_history.capture_fps_actual = captureActualSeries.slice(-MAX_SAMPLES);
_captureFpsActualPeak = Math.max(60, ..._history.capture_fps_actual);
}
// Errors history is a per-second rate. The server already does
// the delta math against its own running totals, so we just
// pull `errors_per_sec` straight through. Set the cumulative
// baseline so the dashboard's next live update doesn't synthesize
// a phantom spike from a stale "0 → live count" comparison.
const errorsSeries = samples
.map((s: any) => s.errors_per_sec)
.filter((v: any) => typeof v === 'number' && Number.isFinite(v));
if (errorsSeries.length > 0) {
_history.errors = errorsSeries.slice(-MAX_SAMPLES);
_errorsPeak = Math.max(1, ..._history.errors);
}
const lastSample = samples[samples.length - 1];
if (typeof lastSample?.total_errors_count === 'number') {
_prevErrorsTotal = lastSample.total_errors_count;
}
if (typeof lastSample?.total_frames_skipped === 'number') {
_prevSkippedTotal = lastSample.total_frames_skipped;
}
// Latest target-sum is shown as a dashed reference line on the
// FPS spark — pin it so the chart doesn't redraw without the
// ceiling line for the brief window before dashboard.ts polls.
if (typeof lastSample?.total_fps_target === 'number' && lastSample.total_fps_target > 0) {
_fpsTargetSum = lastSample.total_fps_target;
}
// Paint value labels from the latest sample so the cards don't
// sit on "—" / "0" until the next dashboard poll. Mirrors what
// `_applyPerfDataToDom` does for CPU/RAM/GPU on the same load.
if (typeof lastSample?.total_fps === 'number') {
_paintFpsValue(lastSample.total_fps);
}
if (typeof lastSample?.total_capture_fps === 'number') {
_paintCaptureFpsValue(lastSample.total_capture_fps);
}
if (typeof lastSample?.total_capture_fps_actual === 'number') {
_paintCaptureFpsActualValue(
lastSample.total_capture_fps_actual,
typeof lastSample.total_fps_target === 'number' ? lastSample.total_fps_target : 0,
typeof lastSample.capture_actual_count === 'number' ? lastSample.capture_actual_count : 0,
);
}
// Network throughput history — direct passthrough from the
// server's per-second rate (computed from cumulative byte counter
// deltas, same shape as `errors_per_sec`).
const networkSeries = samples
.map((s: any) => s.bytes_per_sec)
.filter((v: any) => typeof v === 'number' && Number.isFinite(v));
if (networkSeries.length > 0) {
_history.network = networkSeries.slice(-MAX_SAMPLES);
_networkPeak = Math.max(1024, ..._history.network);
}
if (typeof lastSample?.bytes_per_sec === 'number') {
_paintNetworkValue(
lastSample.bytes_per_sec,
typeof lastSample.total_bytes_sent === 'number' ? lastSample.total_bytes_sent : 0,
);
}
// Device latency history — sparkline plots the avg-across-online
// series. `null` samples (no devices online) become 0 in the
// history so the spark drops to floor instead of going jagged.
const latencySeries = samples
.map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0)
if (latencySeries.length > 0) {
_history.device_latency = latencySeries.slice(-MAX_SAMPLES);
_deviceLatencyPeak = Math.max(50, ..._history.device_latency);
}
if (lastSample) {
_paintDeviceLatencyValue(
typeof lastSample.device_latency_avg_ms === 'number' ? lastSample.device_latency_avg_ms : null,
typeof lastSample.device_latency_max_ms === 'number' ? lastSample.device_latency_max_ms : null,
typeof lastSample.device_online_count === 'number' ? lastSample.device_online_count : 0,
typeof lastSample.device_total_count === 'number' ? lastSample.device_total_count : 0,
);
}
// Send-timing history — plots the avg series; max travels in
// the subtitle/tooltip but isn't a separate spark line to avoid
// adding visual noise.
const sendSeries = samples
.map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0)
if (sendSeries.length > 0) {
_history.send_timing = sendSeries.slice(-MAX_SAMPLES);
const maxes = samples
.map((s: any) => (typeof s.send_timing_max_ms === 'number' && Number.isFinite(s.send_timing_max_ms)) ? s.send_timing_max_ms : 0);
_sendTimingPeak = Math.max(20, ..._history.send_timing, ...maxes);
}
if (lastSample) {
_paintSendTimingValue(
typeof lastSample.send_timing_avg_ms === 'number' ? lastSample.send_timing_avg_ms : 0,
typeof lastSample.send_timing_max_ms === 'number' ? lastSample.send_timing_max_ms : 0,
typeof lastSample.send_timing_count === 'number' ? lastSample.send_timing_count : 0,
);
}
if (typeof lastSample?.errors_per_sec === 'number') {
_paintErrorsValue(
lastSample.errors_per_sec,
lastSample.total_errors_count ?? 0,
lastSample.skipped_per_sec ?? 0,
);
}
}
function _paintFpsValue(fps: number): void {
const valEl = document.getElementById('perf-fps-value');
if (!valEl) return;
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = _fpsTargetSum > 0
? `<span class="perf-fps-ceiling">/ ${Math.round(_fpsTargetSum)}</span>`
: '';
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}
function _paintCaptureFpsValue(fps: number): void {
const valEl = document.getElementById('perf-capture_fps-value');
if (!valEl) return;
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
valEl.innerHTML = `${fpsText}<span class="perf-fps-unit">fps</span>`;
}
function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCount: number): void {
const valEl = document.getElementById('perf-capture_fps_actual-value');
if (valEl) {
if (reportingCount === 0) {
valEl.innerHTML = '<span class="perf-chart-hint">no captures</span>';
} else {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
const ceilingSuffix = targetSum > 0
? `<span class="perf-fps-ceiling">/ ${Math.round(targetSum)}</span>`
: '';
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
}
}
const subEl = document.getElementById('perf-capture_fps_actual-sub');
if (subEl) {
if (reportingCount === 0) {
subEl.textContent = '';
} else if (targetSum > 0) {
const ratio = Math.max(0, Math.min(1, fps / targetSum));
subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
} else {
subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`;
}
}
}
function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate: number): void {
const card = document.querySelector('.perf-errors-cell') as HTMLElement | null;
if (card) card.classList.toggle('has-errors', totalErrors > 0 || errorsRate > 0);
const valEl = document.getElementById('perf-errors-value');
if (valEl) {
const rateText = errorsRate >= 10
? errorsRate.toFixed(0)
: errorsRate >= 1
? errorsRate.toFixed(1)
: '0';
valEl.innerHTML = `${rateText}<span class="perf-fps-unit">/s</span>`;
}
const subEl = document.getElementById('perf-errors-sub');
if (subEl) {
const parts: string[] = [];
if (totalErrors > 0) parts.push(`${totalErrors} total`);
if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`);
subEl.textContent = parts.join(' · ');
}
}
/** Initialize perf section paint from server-side history and wire up /** Initialize perf section paint from server-side history and wire up
* spark hover tooltips. Also fires one immediate `_fetchPerformance` so * spark hover tooltips. Also fires one immediate `_fetchPerformance` so
* the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load * the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load
@@ -924,52 +1414,110 @@ export async function initPerfCharts(): Promise<void> {
/** Re-render the perf grid in place after a layout change. /** Re-render the perf grid in place after a layout change.
* *
* Replaces just the `.perf-charts-grid` element (cell count / order / * Reconciles the existing `.perf-charts-grid` against the desired cell
* mode / window / yScale all read from the layout via `renderPerfSection`), * set + order from the layout. Cells that already exist are kept in
* then replays the cached state into the new DOM: * place (or moved to a new index) their DOM, listeners, color picker
* - sparkline SVGs from the persistent `_history` arrays * state, hidden-by-env state, and label values all survive intact. Only
* - cpu/ram/gpu/temp value labels from `_lastFetchData` * cells that were not visible before are freshly created; cells that
* - patches/total-fps/devices cells from cached external setter args * disappeared from the layout are removed.
* *
* This avoids the full-dashboard innerHTML wipe that previously caused a * Replay of cached state targets only newly-created cells, so unchanged
* frame of layout flicker plus a window where every cell showed "0" / * cells don't get a phantom-scroll animation from a fresh
* "—" until the next dashboard fetch landed. */ * `update*FPS/Errors/Devices` call. Without this, every layout tweak
* (mode toggle, window change, color reset) would visibly reset every
* sparkline. */
export function rerenderPerfGrid(): void { export function rerenderPerfGrid(): void {
const wrapper = document.querySelector('.dashboard-perf-persistent'); const wrapper = document.querySelector('.dashboard-perf-persistent');
if (!wrapper) return; if (!wrapper) return;
const oldGrid = wrapper.querySelector('.perf-charts-grid'); const grid = wrapper.querySelector<HTMLElement>('.perf-charts-grid');
if (!oldGrid) return; if (!grid) return;
// `renderPerfSection()` returns the entire `.perf-charts-grid` div. _syncMode();
const tmp = document.createElement('div'); _registerPerfColorPickers();
tmp.innerHTML = renderPerfSection();
const newGrid = tmp.firstElementChild;
if (!newGrid) return;
oldGrid.replaceWith(newGrid);
// Sparks: paint from existing module-level history (no flash). // Index existing cells by metric key so we can keep / move them
// instead of rebuilding their DOM.
const existing = new Map<string, HTMLElement>();
grid.querySelectorAll<HTMLElement>(':scope > .perf-chart-card[data-metric]').forEach(el => {
const k = el.dataset.metric;
if (k) existing.set(k, el);
});
// Compute desired keys (in order). Anything visible in the layout
// makes the cut; env-detection hides cards via the `hidden` attr,
// which is independent of layout visibility.
const desiredKeys: string[] = [];
for (const cell of getOrderedPerfCells()) {
if (cell.visible) desiredKeys.push(cell.key);
}
// First pass: walk desired order, taking existing elements where
// possible and creating new ones otherwise. Track which keys are
// newly created — those are the only ones that need a value replay.
const newKeys = new Set<string>();
const ordered: HTMLElement[] = [];
for (const key of desiredKeys) {
let el = existing.get(key);
if (el) {
existing.delete(key);
} else {
el = _buildCellElement(key) ?? undefined;
if (!el) continue;
newKeys.add(key);
}
// Update mode + accent on every kept cell. Cheap and idempotent;
// covers the common "mode toggle" path where cell set is unchanged.
el.dataset.perfMode = _mode;
el.style.setProperty('--perf-accent', _getColor(key));
ordered.push(el);
}
// Remove cells that should no longer be visible.
existing.forEach(el => el.remove());
// Reorder via appendChild — moving an already-attached node preserves
// its state (listeners, animation transforms, hidden attr). Skip the
// append when the node is already at the correct position to avoid
// gratuitous DOM mutations.
let cursor: ChildNode | null = grid.firstChild;
for (const el of ordered) {
if (cursor === el) {
cursor = el.nextSibling;
} else {
grid.insertBefore(el, cursor);
// `el` is now positioned before `cursor`; cursor stays the same.
}
}
// Re-render every spark (window / yScale may have changed). Pass
// `animate=false` so we don't trigger phantom scrolls — only real
// poll samples animate.
for (const key of CHART_KEYS) _renderChartSvg(key); for (const key of CHART_KEYS) _renderChartSvg(key);
// Re-apply env-detection visibility (the new HTML always renders // Re-apply env-detection visibility for newly-created gpu/temp cells
// gpu/temp cells without the hidden attr; cached `_hasGpu/_hasTemp` // (the HTML template renders them unhidden by default).
// tell us what to actually do). if (newKeys.has('gpu') && _hasGpu === false) {
if (_hasGpu === false) {
const card = document.getElementById('perf-gpu-card'); const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', ''); if (card) card.setAttribute('hidden', '');
} }
if (_hasTemp === true) { if (newKeys.has('temp') && _hasTemp === true) {
const card = document.getElementById('perf-temp-card'); const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden'); if (card) card.removeAttribute('hidden');
} }
// Replay cached values so labels show real numbers, not "—". // Replay cached state only into newly-created cells. Existing cells
if (_lastFetchData) { // already have correct labels in their DOM.
if (newKeys.size === 0) return;
const needsFetchReplay = newKeys.has('cpu') || newKeys.has('ram')
|| newKeys.has('gpu') || newKeys.has('temp');
if (needsFetchReplay && _lastFetchData) {
_applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false); _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false);
} }
if (_lastPatchesArgs) { if (newKeys.has('patches') && _lastPatchesArgs) {
updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount); updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount);
} }
if (_lastTotalFpsArgs) { if (newKeys.has('fps') && _lastTotalFpsArgs) {
updateTotalFps( updateTotalFps(
_lastTotalFpsArgs.totalFps, _lastTotalFpsArgs.totalFps,
_lastTotalFpsArgs.minFps, _lastTotalFpsArgs.minFps,
@@ -977,14 +1525,42 @@ export function rerenderPerfGrid(): void {
_lastTotalFpsArgs.targetSum, _lastTotalFpsArgs.targetSum,
); );
} }
if (_lastTotalCaptureFpsArgs) { if (newKeys.has('capture_fps') && _lastTotalCaptureFpsArgs) {
updateTotalCaptureFps( updateTotalCaptureFps(
_lastTotalCaptureFpsArgs.totalFps, _lastTotalCaptureFpsArgs.totalFps,
_lastTotalCaptureFpsArgs.minFps, _lastTotalCaptureFpsArgs.minFps,
_lastTotalCaptureFpsArgs.maxFps, _lastTotalCaptureFpsArgs.maxFps,
); );
} }
if (_lastErrorsArgs) { if (newKeys.has('capture_fps_actual') && _lastTotalCaptureFpsActualArgs) {
updateTotalCaptureFpsActual(
_lastTotalCaptureFpsActualArgs.totalFps,
_lastTotalCaptureFpsActualArgs.targetSum,
_lastTotalCaptureFpsActualArgs.reportingCount,
);
}
if (newKeys.has('network') && _lastNetworkArgs) {
updateNetworkThroughput(
_lastNetworkArgs.bytesPerSec,
_lastNetworkArgs.totalBytes,
);
}
if (newKeys.has('device_latency') && _lastDeviceLatencyArgs) {
updateDeviceLatency(
_lastDeviceLatencyArgs.avgMs,
_lastDeviceLatencyArgs.maxMs,
_lastDeviceLatencyArgs.onlineCount,
_lastDeviceLatencyArgs.totalCount,
);
}
if (newKeys.has('send_timing') && _lastSendTimingArgs) {
updateSendTiming(
_lastSendTimingArgs.avgMs,
_lastSendTimingArgs.maxMs,
_lastSendTimingArgs.reportingCount,
);
}
if (newKeys.has('errors') && _lastErrorsArgs) {
// Replay must not synthesize a fake spike from delta against an // Replay must not synthesize a fake spike from delta against an
// older baseline (e.g. layout-change re-render after a long // older baseline (e.g. layout-change re-render after a long
// session). Pin the baseline to the cached totals so the call // session). Pin the baseline to the cached totals so the call
@@ -997,7 +1573,7 @@ export function rerenderPerfGrid(): void {
_lastErrorsArgs.pollMs, _lastErrorsArgs.pollMs,
); );
} }
if (_lastDevicesArgs) { if (newKeys.has('devices') && _lastDevicesArgs) {
updateDevices(_lastDevicesArgs); updateDevices(_lastDevicesArgs);
} }
} }
@@ -1026,7 +1602,13 @@ function _ensureTooltip(): HTMLDivElement {
/** Format a sampled value per metric for the tooltip line. */ /** Format a sampled value per metric for the tooltip line. */
function _formatSampleValue(key: string, v: number): string { function _formatSampleValue(key: string, v: number): string {
if (key === 'temp') return `${v.toFixed(1)}°C`; if (key === 'temp') return `${v.toFixed(1)}°C`;
if (key === 'fps' || key === 'capture_fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`; if (key === 'fps' || key === 'capture_fps' || key === 'capture_fps_actual') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
if (key === 'network') {
if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`;
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`;
return `${v.toFixed(0)} B/s`;
}
if (key === 'device_latency' || key === 'send_timing') return `${v.toFixed(v < 10 ? 1 : 0)} ms`;
if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`; if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`;
return `${v.toFixed(1)}%`; return `${v.toFixed(1)}%`;
} }
@@ -1037,7 +1619,11 @@ function _metricLabel(key: string): string {
if (key === 'gpu') return 'GPU'; if (key === 'gpu') return 'GPU';
if (key === 'temp') return 'Temp'; if (key === 'temp') return 'Temp';
if (key === 'fps') return 'Total FPS'; if (key === 'fps') return 'Total FPS';
if (key === 'capture_fps') return 'Total Capture FPS'; if (key === 'capture_fps') return 'Total Source FPS';
if (key === 'capture_fps_actual') return 'Total Capture FPS';
if (key === 'network') return 'Network';
if (key === 'device_latency') return 'Device Latency';
if (key === 'send_timing') return 'Send Timing';
if (key === 'errors') return 'Errors'; if (key === 'errors') return 'Errors';
return key.toUpperCase(); return key.toUpperCase();
} }
@@ -9,11 +9,12 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_TRASH, ICON_LINK, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK,
} from '../core/icons.ts'; } from '../core/icons.ts';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts'; import { wrapCard, cardColorStyle } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { EntityPalette } from '../core/entity-palette.ts'; import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts'; import { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.ts'; import { isActiveTab } from '../core/tab-registry.ts';
@@ -81,35 +82,81 @@ export function createSceneCard(preset: ScenePreset) {
const automations = automationsCacheObj.data || []; const automations = automationsCacheObj.data || [];
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length; const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
usedByCount > 0 ? `${ICON_LINK} ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
].filter(Boolean);
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
const colorStyle = cardColorStyle(preset.id); // ── Badge: SCN · XX (last 2 hex chars of id, mirrors AUTO · 07 in
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}> // automations.ts and SCN · 04 in cards-redesign-demo-v2). ──
<div class="card-top-actions"> const shortId = (preset.id || '').replace(/^scn_/i, '').slice(-2).toUpperCase() || 'NA';
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">${ICON_TRASH}</button>
</div> // ── Meta line: target count + last-updated timestamp. The "used by
<div class="card-header"> // N automations" hint moves down into a chip so it reads as a
<div class="card-title" title="${escapeHtml(preset.name)}"><span class="card-title-text">${escapeHtml(preset.name)}</span></div> // crosslink, not a count. ──
</div> const metaParts: string[] = [];
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''} if (targetCount > 0) metaParts.push(`${targetCount} ${t('scenes.targets_count')}`);
<div class="stream-card-props"> if (updated) metaParts.push(updated);
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')} const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
</div> // ── Chips: usage crosslink + target count quick-jump. ──
${renderTagChips(preset.tags)} const chips: ModChipOpts[] = [];
<div class="card-actions"> if (usedByCount > 0) {
<button class="btn btn-icon btn-secondary" data-action="clone-scene" data-id="${preset.id}" title="${t('common.clone')}">${ICON_CLONE}</button> chips.push({
<button class="btn btn-icon btn-secondary" data-action="edit-scene" data-id="${preset.id}" title="${t('scenes.edit')}">${ICON_EDIT}</button> icon: ICON_LINK,
<button class="btn btn-icon btn-secondary" data-action="recapture-scene" data-id="${preset.id}" title="${t('scenes.recapture')}">${ICON_REFRESH}</button> text: t('scene_preset.used_by').replace('%d', String(usedByCount)),
<button class="btn btn-icon btn-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button> variant: 'tag',
${cardColorButton(preset.id, 'data-scene-id')} });
</div> }
</div>`;
// ── 2 dim LEDs in the bezel — scenes are stored snapshots, never
// "running"; the two-LED cluster mirrors the demo. ──
const leds: LedState[] = ['off', 'off'];
const mod: ModCardOpts = {
head: {
badge: { text: `SCN · ${shortId}` },
name: preset.name,
metaHtml,
leds,
menu: {
duplicateOnclick: `cloneScenePreset('${preset.id}')`,
hideOnclick: `toggleCardHidden('scenes','${preset.id}')`,
deleteOnclick: `deleteScenePreset('${preset.id}')`,
},
},
body: {
desc: preset.description || undefined,
chips: chips.length ? chips : undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('scenes.status.preset'),
primaryAction: {
label: t('scenes.action.activate'),
icon: ICON_START,
onclick: `activateScenePreset('${preset.id}')`,
title: t('scenes.activate'),
variant: 'go',
},
secondaryActions: [{
label: t('scenes.action.recapture'),
icon: ICON_REFRESH,
onclick: `recaptureScenePreset('${preset.id}')`,
title: t('scenes.recapture'),
}],
iconActions: [{
icon: ICON_EDIT,
onclick: `editScenePreset('${preset.id}')`,
title: t('scenes.edit'),
}],
},
};
const cardHtml = wrapCard({
dataAttr: 'data-scene-id',
id: preset.id,
mod,
});
const tagsHtml = renderTagChips(preset.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ===== Dashboard section (compact cards) ===== // ===== Dashboard section (compact cards) =====
@@ -510,7 +557,7 @@ export function initScenePresetDelegation(container: HTMLElement): void {
if (action === 'navigate-scene') { if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button // Only navigate if click wasn't on a child button
if ((e.target as HTMLElement).closest('button')) return; if ((e.target as HTMLElement).closest('button')) return;
navigateToCard('automations', null, 'scenes', 'data-scene-id', id!); navigateToCard('automations', 'scenes', 'scenes', 'data-scene-id', id!);
return; return;
} }
+340 -62
View File
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE, ICON_BELL, ICON_MONITOR, ICON_X, ICON_LIGHTBULB } from '../core/icons.ts'; import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { openAuthedWs } from '../core/ws-auth.ts'; import { openAuthedWs } from '../core/ws-auth.ts';
import { import {
@@ -19,6 +19,7 @@ import {
// ─── External URL (used by other modules for user-visible URLs) ── // ─── External URL (used by other modules for user-visible URLs) ──
let _externalUrl = ''; let _externalUrl = '';
let _externalUrlInputBound = false;
/** Get the configured external base URL (empty string = not set). */ /** Get the configured external base URL (empty string = not set). */
export function getExternalUrl(): string { export function getExternalUrl(): string {
@@ -33,6 +34,24 @@ export function getBaseOrigin(): string {
return _externalUrl || window.location.origin; return _externalUrl || window.location.origin;
} }
/** Show or hide the save-bar that sits below the External URL field. */
function _updateExternalUrlSaveBar(): void {
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
const bar = document.getElementById('settings-external-url-save-bar');
if (!input || !bar) return;
const dirty = input.value.trim().replace(/\/+$/, '') !== _externalUrl;
bar.hidden = !dirty;
}
/** Wire input listener once so editing the External URL toggles the save-bar. */
function _bindExternalUrlInput(): void {
if (_externalUrlInputBound) return;
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
if (!input) return;
input.addEventListener('input', _updateExternalUrlSaveBar);
_externalUrlInputBound = true;
}
export async function loadExternalUrl(): Promise<void> { export async function loadExternalUrl(): Promise<void> {
try { try {
const resp = await fetchWithAuth('/system/external-url'); const resp = await fetchWithAuth('/system/external-url');
@@ -41,6 +60,8 @@ export async function loadExternalUrl(): Promise<void> {
_externalUrl = data.external_url || ''; _externalUrl = data.external_url || '';
const input = document.getElementById('settings-external-url') as HTMLInputElement | null; const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
if (input) input.value = _externalUrl; if (input) input.value = _externalUrl;
_bindExternalUrlInput();
_updateExternalUrlSaveBar();
} catch (err) { } catch (err) {
console.error('Failed to load external URL:', err); console.error('Failed to load external URL:', err);
} }
@@ -62,6 +83,7 @@ export async function saveExternalUrl(): Promise<void> {
const data = await resp.json(); const data = await resp.json();
_externalUrl = data.external_url || ''; _externalUrl = data.external_url || '';
input.value = _externalUrl; input.value = _externalUrl;
_updateExternalUrlSaveBar();
showToast(t('settings.external_url.saved'), 'success'); showToast(t('settings.external_url.saved'), 'success');
} catch (err) { } catch (err) {
console.error('Failed to save external URL:', err); console.error('Failed to save external URL:', err);
@@ -69,13 +91,23 @@ export async function saveExternalUrl(): Promise<void> {
} }
} }
/** Discard pending edits and restore the persisted External URL value. */
export function revertExternalUrl(): void {
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
if (!input) return;
input.value = _externalUrl;
_updateExternalUrlSaveBar();
}
// ─── Settings-modal tab switching ─────────────────────────── // ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab'; const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void { export function switchSettingsTab(tabId: string): void {
let activeBtn: HTMLElement | null = null; let activeBtn: HTMLElement | null = null;
document.querySelectorAll('.settings-tab-btn').forEach(btn => { // Both selectors are queried so older cached templates with the legacy
// top tab strip continue to work alongside the new left rail.
document.querySelectorAll('.settings-tab-btn, .settings-rail-btn').forEach(btn => {
const isActive = (btn as HTMLElement).dataset.settingsTab === tabId; const isActive = (btn as HTMLElement).dataset.settingsTab === tabId;
btn.classList.toggle('active', isActive); btn.classList.toggle('active', isActive);
if (isActive) activeBtn = btn as HTMLElement; if (isActive) activeBtn = btn as HTMLElement;
@@ -83,10 +115,18 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => { document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`); panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
}); });
// Keep the active tab visible inside the (possibly scrolling) tab bar. // Keep the active tab visible inside the (possibly scrolling) tab bar / rail.
if (activeBtn) { if (activeBtn) {
(activeBtn as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); (activeBtn as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
} }
// Swap the modal channel stripe color to match the tab's rail accent.
const modalContent = document.querySelector('#settings-modal .modal-content') as HTMLElement | null;
const railCh = (activeBtn as HTMLElement | null)?.dataset.railCh;
if (modalContent && railCh) {
modalContent.style.setProperty('--modal-ch', `var(--ch-${railCh}, var(--ch-amber))`);
} else if (modalContent) {
modalContent.style.removeProperty('--modal-ch');
}
// Remember so the next openSettingsModal() re-opens this tab. // Remember so the next openSettingsModal() re-opens this tab.
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ } try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
// Lazy-render the appearance tab content // Lazy-render the appearance tab content
@@ -113,6 +153,13 @@ export function switchSettingsTab(tabId: string): void {
/** @type {WebSocket|null} */ /** @type {WebSocket|null} */
let _logWs: WebSocket | null = null; let _logWs: WebSocket | null = null;
/** Connection state drives LED cluster, patch indicator, and the
* signal-flow strip on the console surface. */
type LogConnectionState = 'idle' | 'connecting' | 'live' | 'error';
/** Live tally of streamed lines, by severity. Reset on Clear. */
const _logStats = { total: 0, warn: 0, err: 0 };
/** Level ordering for filter comparisons */ /** Level ordering for filter comparisons */
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 }; const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
@@ -142,17 +189,101 @@ function _linePassesFilter(line: string): boolean {
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0); return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
} }
/** Mirror the live tally into the .mod-metric cells and toggle the
* channel-tinted "has-warn"/"has-errors" classes for non-zero counts. */
function _renderLogStats(): void {
const total = document.getElementById('log-stat-total');
const warn = document.getElementById('log-stat-warn');
const err = document.getElementById('log-stat-err');
if (total) total.textContent = String(_logStats.total);
if (warn) warn.textContent = String(_logStats.warn);
if (err) err.textContent = String(_logStats.err);
document.getElementById('log-stat-warn-cell')?.classList.toggle('has-warn', _logStats.warn > 0);
document.getElementById('log-stat-err-cell')?.classList.toggle('has-errors', _logStats.err > 0);
}
function _resetLogStats(): void {
_logStats.total = 0;
_logStats.warn = 0;
_logStats.err = 0;
_renderLogStats();
}
/** Map the live connection state onto the header LED cluster, the
* patch indicator dot/label, and the signal-flow class. */
function _setLogConnectionState(state: LogConnectionState): void {
const overlay = document.getElementById('log-overlay');
overlay?.classList.toggle('is-streaming', state === 'live');
const leds = Array.from(document.querySelectorAll<HTMLElement>('#log-viewer-leds .led'));
leds.forEach((led, idx) => {
led.classList.remove('on', 'blink', 'fault');
if (state === 'live') {
led.classList.add('on');
if (idx > 0) led.classList.add('blink');
} else if (state === 'connecting') {
if (idx === 0) led.classList.add('on', 'blink');
} else if (state === 'error') {
if (idx === 0) led.classList.add('fault');
}
});
const patchDot = document.querySelector<HTMLElement>('#log-patch-indicator .patch-dot');
patchDot?.classList.toggle('is-live', state === 'live');
const patchLabel = document.getElementById('log-patch-label');
if (patchLabel) {
const key =
state === 'live' ? 'settings.logs.patch.live'
: state === 'connecting' ? 'settings.logs.patch.connecting'
: state === 'error' ? 'settings.logs.patch.error'
: 'settings.logs.patch.idle';
const fallback =
state === 'live' ? 'STREAMING'
: state === 'connecting' ? 'CONNECTING'
: state === 'error' ? 'OFFLINE'
: 'STANDBY';
const translated = t(key);
// t() returns the key itself when a translation is missing —
// detect that and fall through to the English label.
patchLabel.textContent = translated === key ? fallback : translated;
patchLabel.dataset.i18n = key;
}
const btn = document.getElementById('log-viewer-connect-btn');
if (btn) {
const isConnected = state === 'live' || state === 'connecting';
const labelKey = isConnected ? 'settings.logs.disconnect' : 'settings.logs.connect';
const labelEl = btn.querySelector('span') ?? btn;
labelEl.textContent = t(labelKey);
if (labelEl instanceof HTMLElement) labelEl.dataset.i18n = labelKey;
btn.classList.toggle('mod-btn-go', !isConnected);
btn.classList.toggle('mod-btn-stop', isConnected);
}
}
function _appendLine(line: string): void { function _appendLine(line: string): void {
// Skip keepalive empty pings // Skip keepalive empty pings
if (!line) return; if (!line) return;
const level = _detectLevel(line);
// Tally is independent of the active filter — counts always reflect
// the unfiltered stream so the user can see the full picture.
_logStats.total += 1;
if (level === 'WARNING') _logStats.warn += 1;
if (level === 'ERROR' || level === 'CRITICAL') _logStats.err += 1;
_renderLogStats();
document.getElementById('log-overlay')?.classList.add('has-data');
if (!_linePassesFilter(line)) return; if (!_linePassesFilter(line)) return;
const output = document.getElementById('log-viewer-output'); const output = document.getElementById('log-viewer-output');
if (!output) return; if (!output) return;
const level = _detectLevel(line);
const cls = _levelClass(level); const cls = _levelClass(level);
const span = document.createElement('span'); const span = document.createElement('span');
if (cls) span.className = cls; if (cls) span.className = cls;
span.textContent = line + '\n'; span.textContent = line + '\n';
@@ -163,22 +294,22 @@ function _appendLine(line: string): void {
} }
export function connectLogViewer(): void { export function connectLogViewer(): void {
const btn = document.getElementById('log-viewer-connect-btn');
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) { if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
// Disconnect // Disconnect
_logWs.close(); _logWs.close();
_logWs = null; _logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } _setLogConnectionState('idle');
return; return;
} }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/v1/system/logs/ws`; const url = `${proto}//${location.host}/api/v1/system/logs/ws`;
_setLogConnectionState('connecting');
openAuthedWs(url).then((ws) => { openAuthedWs(url).then((ws) => {
_logWs = ws; _logWs = ws;
if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; } _setLogConnectionState('live');
ws.onmessage = (evt) => { ws.onmessage = (evt) => {
_appendLine(evt.data); _appendLine(evt.data);
@@ -186,14 +317,16 @@ export function connectLogViewer(): void {
ws.onerror = () => { ws.onerror = () => {
showToast(t('settings.logs.error'), 'error'); showToast(t('settings.logs.error'), 'error');
_setLogConnectionState('error');
}; };
ws.onclose = () => { ws.onclose = () => {
_logWs = null; _logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } _setLogConnectionState('idle');
}; };
}).catch(() => { }).catch(() => {
showToast(t('settings.logs.error'), 'error'); showToast(t('settings.logs.error'), 'error');
_setLogConnectionState('error');
}); });
} }
@@ -202,13 +335,14 @@ export function disconnectLogViewer(): void {
_logWs.close(); _logWs.close();
_logWs = null; _logWs = null;
} }
const btn = document.getElementById('log-viewer-connect-btn'); _setLogConnectionState('idle');
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
} }
export function clearLogViewer(): void { export function clearLogViewer(): void {
const output = document.getElementById('log-viewer-output'); const output = document.getElementById('log-viewer-output');
if (output) output.innerHTML = ''; if (output) output.innerHTML = '';
_resetLogStats();
document.getElementById('log-overlay')?.classList.remove('has-data');
} }
/** Re-render the log output according to the current filter selection. */ /** Re-render the log output according to the current filter selection. */
@@ -278,10 +412,8 @@ let _logLevelIconSelect: IconSelect | null = null;
let _autoBackupIntervalIconSelect: IconSelect | null = null; let _autoBackupIntervalIconSelect: IconSelect | null = null;
let _shutdownActionIconSelect: IconSelect | null = null; let _shutdownActionIconSelect: IconSelect | null = null;
// Notification matrix: one IconSelect per event type. Constructed lazily // Notifications: the visual matrix is now the source of truth — see
// when the Notifications tab is first opened so the icon palette and i18n // initNotificationsPanel() / _setNotifMatrixSelection() below.
// strings have a chance to load.
const _notifIconSelects: Partial<Record<keyof NotificationPreferences['channels'], IconSelect>> = {};
type ShutdownAction = 'stop_targets' | 'nothing'; type ShutdownAction = 'stop_targets' | 'nothing';
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const; const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
@@ -359,7 +491,8 @@ export function openSettingsModal(): void {
} }
} }
// Initialize auto-backup interval icon select // Initialize auto-backup interval icon select. The onChange callback
// auto-persists the section — there is no longer a manual Save button.
if (!_autoBackupIntervalIconSelect) { if (!_autoBackupIntervalIconSelect) {
const sel = document.getElementById('auto-backup-interval') as HTMLSelectElement | null; const sel = document.getElementById('auto-backup-interval') as HTMLSelectElement | null;
if (sel) { if (sel) {
@@ -367,6 +500,7 @@ export function openSettingsModal(): void {
target: sel, target: sel,
items: _getHourIntervalItems(), items: _getHourIntervalItems(),
columns: 3, columns: 3,
onChange: () => saveAutoBackupSettings(),
}); });
} }
} }
@@ -387,9 +521,26 @@ export function openSettingsModal(): void {
loadApiKeysList(); loadApiKeysList();
loadExternalUrl(); loadExternalUrl();
loadAutoBackupSettings(); loadAutoBackupSettings();
_bindAutoBackupListeners();
loadBackupList(); loadBackupList();
loadLogLevel(); loadLogLevel();
loadShutdownAction(); loadShutdownAction();
_seedRailFooter();
// Refresh the update status so the rail badge ("update available" pill
// on the Updates tab) is current when the modal opens — it would
// otherwise reflect whatever state the app loaded with.
if (typeof (window as any).loadUpdateStatus === 'function') {
(window as any).loadUpdateStatus();
}
}
/** Populate the rail footer with version info, mirroring the page header
* badge so the modal feels grounded. Idempotent safe to call repeatedly. */
function _seedRailFooter(): void {
const footer = document.getElementById('settings-rail-build');
if (!footer) return;
const version = document.getElementById('version-number')?.textContent?.trim() || '';
footer.textContent = version ? version : '';
} }
export function closeSettingsModal(): void { export function closeSettingsModal(): void {
@@ -500,15 +651,36 @@ export async function loadAutoBackupSettings(): Promise<void> {
} else { } else {
statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never'); statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never');
} }
const pill = document.getElementById('auto-backup-status-pill');
if (pill) {
if (data.enabled) {
pill.textContent = t('settings.auto_backup.pill.running');
pill.hidden = false;
} else {
pill.hidden = true;
}
}
} catch (err) { } catch (err) {
console.error('Failed to load auto-backup settings:', err); console.error('Failed to load auto-backup settings:', err);
} }
} }
/** Persist auto-backup settings. The Save button has been removed
* this is invoked silently from change-listeners on the three fields
* (enabled checkbox, interval IconSelect, max-backups input).
* Errors still surface as toasts; success is silent because the
* Auto-Backup section's own pill/status text reflects the new state. */
export async function saveAutoBackupSettings(): Promise<void> { export async function saveAutoBackupSettings(): Promise<void> {
const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked; const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null;
const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value); const intervalEl = document.getElementById('auto-backup-interval') as HTMLInputElement | null;
const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).value, 10); const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null;
if (!enabledEl || !intervalEl || !maxEl) return;
const enabled = enabledEl.checked;
const interval_hours = parseFloat(intervalEl.value);
const max_backups = parseInt(maxEl.value, 10);
if (Number.isNaN(interval_hours) || Number.isNaN(max_backups)) return;
try { try {
const resp = await fetchWithAuth('/system/auto-backup/settings', { const resp = await fetchWithAuth('/system/auto-backup/settings', {
@@ -519,7 +691,6 @@ export async function saveAutoBackupSettings(): Promise<void> {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('settings.auto_backup.saved'), 'success');
loadAutoBackupSettings(); loadAutoBackupSettings();
loadBackupList(); loadBackupList();
} catch (err) { } catch (err) {
@@ -528,6 +699,22 @@ export async function saveAutoBackupSettings(): Promise<void> {
} }
} }
/** Wire change listeners on the three auto-backup fields so any edit
* auto-saves. Called once per modal open; idempotent. */
let _autoBackupListenersBound = false;
function _bindAutoBackupListeners(): void {
if (_autoBackupListenersBound) return;
const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null;
const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null;
if (!enabledEl || !maxEl) return;
// Toggle: persist on every flip.
enabledEl.addEventListener('change', () => { saveAutoBackupSettings(); });
// Number input: fires on blur / Enter — avoids saving on every keystroke
// while the user is still typing a multi-digit value.
maxEl.addEventListener('change', () => { saveAutoBackupSettings(); });
_autoBackupListenersBound = true;
}
export async function triggerBackupNow(): Promise<void> { export async function triggerBackupNow(): Promise<void> {
try { try {
const resp = await fetchWithAuth('/system/auto-backup/trigger', { method: 'POST' }); const resp = await fetchWithAuth('/system/auto-backup/trigger', { method: 'POST' });
@@ -548,40 +735,50 @@ export async function triggerBackupNow(): Promise<void> {
export async function loadBackupList(): Promise<void> { export async function loadBackupList(): Promise<void> {
const container = document.getElementById('saved-backups-list')!; const container = document.getElementById('saved-backups-list')!;
const meta = document.getElementById('saved-backups-meta');
container.setAttribute('data-empty', t('settings.saved_backups.empty'));
try { try {
const resp = await fetchWithAuth('/system/backups'); const resp = await fetchWithAuth('/system/backups');
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
if (data.count === 0) { if (data.count === 0) {
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${t('settings.saved_backups.empty')}</div>`; container.innerHTML = '';
if (meta) meta.hidden = true;
return; return;
} }
let totalBytes = 0;
container.innerHTML = data.backups.map(b => { container.innerHTML = data.backups.map(b => {
const sizeBytes = b.size_bytes || 0; const sizeBytes = b.size_bytes || 0;
totalBytes += sizeBytes;
const sizeStr = sizeBytes >= 1024 * 1024 const sizeStr = sizeBytes >= 1024 * 1024
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB' ? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
: (sizeBytes / 1024).toFixed(1) + ' KB'; : (sizeBytes / 1024).toFixed(1) + ' KB';
const date = new Date(b.created_at).toLocaleString(); const date = new Date(b.created_at).toLocaleString();
const isAuto = b.filename.startsWith('ledgrab-autobackup-'); const isAuto = b.filename.startsWith('ledgrab-autobackup-');
const typeBadge = isAuto const typeKey = isAuto ? 'settings.saved_backups.type.auto' : 'settings.saved_backups.type.manual';
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>` return `<div class="backup-row">
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`; <div>
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;"> <div class="backup-name" title="${b.filename}">${date}</div>
${typeBadge} <div class="backup-meta">${sizeStr} · ${t(typeKey)}</div>
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
<span>${date}</span>
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeStr}</span>
</div> </div>
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button> <button class="icon-btn" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}">${ICON_UNDO}</button>
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button> <button class="icon-btn" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}" style="padding:2px 6px;font-size:0.8rem;">&#x2715;</button> <button class="icon-btn danger" onclick="deleteSavedBackup('${b.filename}')" title="${t('settings.saved_backups.delete')}"><svg class="icon" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
</div>`; </div>`;
}).join(''); }).join('');
if (meta) {
meta.hidden = false;
const totalStr = totalBytes >= 1024 * 1024
? (totalBytes / (1024 * 1024)).toFixed(1) + ' MB'
: (totalBytes / 1024).toFixed(1) + ' KB';
meta.textContent = `${data.count} · ${totalStr}`;
}
} catch (err) { } catch (err) {
console.error('Failed to load backup list:', err); console.error('Failed to load backup list:', err);
container.innerHTML = ''; container.innerHTML = '';
if (meta) meta.hidden = true;
} }
} }
@@ -668,26 +865,35 @@ export async function deleteSavedBackup(filename: string): Promise<void> {
export async function loadApiKeysList(): Promise<void> { export async function loadApiKeysList(): Promise<void> {
const container = document.getElementById('settings-api-keys-list'); const container = document.getElementById('settings-api-keys-list');
if (!container) return; if (!container) return;
const meta = document.getElementById('settings-api-keys-meta');
try { try {
const resp = await fetchWithAuth('/system/api-keys'); const resp = await fetchWithAuth('/system/api-keys');
if (!resp.ok) { if (!resp.ok) {
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`; container.innerHTML = `<div class="api-key-empty-note">${t('settings.api_keys.load_error')}</div>`;
if (meta) meta.hidden = true;
return; return;
} }
const data = await resp.json(); const data = await resp.json();
if (data.count === 0) { if (data.count === 0) {
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.empty')}</div>`; container.innerHTML = `<div class="api-key-empty-note">${t('settings.api_keys.empty')}</div>`;
if (meta) meta.hidden = true;
return; return;
} }
container.innerHTML = data.keys.map(k => container.innerHTML = data.keys.map(k =>
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);"> `<div class="api-key-row">
<span style="font-weight:600;min-width:80px;">${k.label}</span> <span class="api-key-name">${k.label}</span>
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code> <span class="api-key-mask">${k.masked}</span>
<span class="api-key-tag">${t('settings.api_keys.read_only')}</span>
</div>` </div>`
).join(''); ).join('');
if (meta) {
meta.hidden = false;
meta.textContent = `${data.count} ${data.count === 1 ? t('settings.api_keys.meta.one') : t('settings.api_keys.meta.many')}`;
}
} catch (err) { } catch (err) {
console.error('Failed to load API keys:', err); console.error('Failed to load API keys:', err);
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
if (meta) meta.hidden = true;
} }
} }
@@ -776,33 +982,88 @@ const _NOTIF_EVENT_KEYS = [
] as const; ] as const;
type NotifEventKey = typeof _NOTIF_EVENT_KEYS[number]; type NotifEventKey = typeof _NOTIF_EVENT_KEYS[number];
function _getNotifChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
{ value: 'none', icon: ICON_X, label: t('settings.notifications.channel.none.label'), desc: t('settings.notifications.channel.none.desc') },
{ value: 'snack', icon: ICON_BELL, label: t('settings.notifications.channel.snack.label'), desc: t('settings.notifications.channel.snack.desc') },
{ value: 'os', icon: ICON_MONITOR, label: t('settings.notifications.channel.os.label'), desc: t('settings.notifications.channel.os.desc') },
{ value: 'both', icon: ICON_LIGHTBULB, label: t('settings.notifications.channel.both.label'), desc: t('settings.notifications.channel.both.desc') },
];
}
function _isNotifChannel(v: string): v is NotificationChannel { function _isNotifChannel(v: string): v is NotificationChannel {
return v === 'none' || v === 'snack' || v === 'os' || v === 'both'; return v === 'none' || v === 'snack' || v === 'os' || v === 'both';
} }
let _notifPrefsLoaded = false; let _notifPrefsLoaded = false;
let _notifMatrixWired = false;
const _NOTIF_CHANNELS: NotificationChannel[] = ['snack', 'os', 'both', 'none'];
/** Reflect a (event, channel) selection in the visual matrix and the
* hidden underlying <select> that notifications-watcher.ts reads from. */
function _setNotifMatrixSelection(event: NotifEventKey, channel: NotificationChannel): void {
// 1. Visual cells
document.querySelectorAll(
`#settings-notif-matrix .notif-matrix-opt[data-event="${event}"]`,
).forEach((cell) => {
const c = cell as HTMLElement;
c.classList.toggle('selected', c.dataset.channel === channel);
c.setAttribute('aria-checked', c.dataset.channel === channel ? 'true' : 'false');
});
// 2. Underlying hidden <select> (must already contain a matching <option>).
const sel = document.getElementById(
`settings-notif-${event.replace(/_/g, '-')}`,
) as HTMLSelectElement | null;
if (sel) {
// Ensure the option exists — the matrix is the source of truth, so we
// populate the select on demand instead of relying on IconSelect.
if (!sel.querySelector(`option[value="${channel}"]`)) {
for (const c of _NOTIF_CHANNELS) {
if (sel.querySelector(`option[value="${c}"]`)) continue;
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
sel.appendChild(opt);
}
}
sel.value = channel;
}
}
export async function initNotificationsPanel(): Promise<void> { export async function initNotificationsPanel(): Promise<void> {
// Build IconSelects (idempotent). // Wire matrix cells (idempotent).
for (const key of _NOTIF_EVENT_KEYS) { if (!_notifMatrixWired) {
if (_notifIconSelects[key]) continue; const matrix = document.getElementById('settings-notif-matrix');
const sel = document.getElementById(`settings-notif-${key.replace(/_/g, '-')}`) as HTMLSelectElement | null; if (matrix) {
if (!sel) continue; // Pre-populate hidden selects with all channel options so
_notifIconSelects[key] = new IconSelect({ // saveNotifPreferencesFromUi() can read .value back without
target: sel, // depending on IconSelect.
items: _getNotifChannelItems(), for (const event of _NOTIF_EVENT_KEYS) {
columns: 2, const sel = document.getElementById(
onChange: () => saveNotifPreferencesFromUi(), `settings-notif-${event.replace(/_/g, '-')}`,
}); ) as HTMLSelectElement | null;
if (!sel) continue;
for (const c of _NOTIF_CHANNELS) {
if (sel.querySelector(`option[value="${c}"]`)) continue;
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
sel.appendChild(opt);
}
}
matrix.addEventListener('click', (e) => {
const cell = (e.target as HTMLElement).closest('.notif-matrix-opt') as HTMLElement | null;
if (!cell) return;
const event = cell.dataset.event;
const channel = cell.dataset.channel;
if (!event || !channel || !_isNotifChannel(channel)) return;
if (!_NOTIF_EVENT_KEYS.includes(event as NotifEventKey)) return;
_setNotifMatrixSelection(event as NotifEventKey, channel);
saveNotifPreferencesFromUi();
});
matrix.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
const cell = (ke.target as HTMLElement).closest('.notif-matrix-opt') as HTMLElement | null;
if (!cell) return;
ke.preventDefault();
cell.click();
});
_notifMatrixWired = true;
}
} }
const bgInput = document.getElementById('settings-notif-background') as HTMLInputElement | null; const bgInput = document.getElementById('settings-notif-background') as HTMLInputElement | null;
@@ -815,7 +1076,7 @@ export async function initNotificationsPanel(): Promise<void> {
try { try {
const prefs = await refreshNotificationPreferences(); const prefs = await refreshNotificationPreferences();
for (const key of _NOTIF_EVENT_KEYS) { for (const key of _NOTIF_EVENT_KEYS) {
_notifIconSelects[key]?.setValue(prefs.channels[key]); _setNotifMatrixSelection(key, prefs.channels[key]);
} }
if (bgInput) bgInput.checked = prefs.background_discovery_enabled; if (bgInput) bgInput.checked = prefs.background_discovery_enabled;
_notifPrefsLoaded = true; _notifPrefsLoaded = true;
@@ -829,18 +1090,35 @@ export async function initNotificationsPanel(): Promise<void> {
function _refreshNotifPermissionState(): void { function _refreshNotifPermissionState(): void {
const stateEl = document.getElementById('settings-notif-permission-state'); const stateEl = document.getElementById('settings-notif-permission-state');
if (!stateEl) return;
const perm = getOsPermission(); const perm = getOsPermission();
const key = perm === 'granted' const key = perm === 'granted'
? 'settings.notifications.permission.state.granted' ? 'settings.notifications.permission.state.granted'
: perm === 'denied' : perm === 'denied'
? 'settings.notifications.permission.state.denied' ? 'settings.notifications.permission.state.denied'
: 'settings.notifications.permission.state.default'; : 'settings.notifications.permission.state.default';
stateEl.textContent = t(key); if (stateEl) stateEl.textContent = t(key);
// Target the Grant button specifically — not the bare `button` selector,
// which would also match the `.hint-toggle` ? button now sitting in the
// same row and disable it whenever permission is denied/granted.
const grantBtn = document.querySelector( const grantBtn = document.querySelector(
'#settings-notif-permission-row button', '#settings-notif-permission-row .btn',
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
if (grantBtn) grantBtn.disabled = perm === 'granted' || perm === 'denied'; if (grantBtn) grantBtn.disabled = perm === 'granted' || perm === 'denied';
// Update the rack-panel meta pill so the section header reflects the
// current OS permission state at a glance.
const pill = document.getElementById('settings-notif-permission-pill');
if (pill) {
if (perm === 'granted') {
pill.textContent = t('settings.notifications.permission.pill.granted');
pill.hidden = false;
} else if (perm === 'denied') {
pill.textContent = t('settings.notifications.permission.pill.denied');
pill.hidden = false;
} else {
pill.hidden = true;
}
}
} }
async function saveNotifPreferencesFromUi(): Promise<void> { async function saveNotifPreferencesFromUi(): Promise<void> {
+380 -228
View File
@@ -67,6 +67,7 @@ import {
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
@@ -344,50 +345,92 @@ const _streamSectionMap = {
sync: [csSyncClocks], sync: [csSyncClocks],
}; };
type StreamCardRenderer = (stream: any) => string; // ── Per-type chip + meta builders for picture-source cards. Replaces
// the legacy `.stream-card-props` blocks. Each builder returns the
// mod-card chip array plus a meta line for `.mod-meta`. ──
type StreamCardDetails = {
badgeText: string;
metaHtml: string;
chips: ModChipOpts[];
};
type StreamCardDetailsBuilder = (stream: any) => StreamCardDetails;
const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = { const PICTURE_SOURCE_CARD_DETAILS: Record<string, StreamCardDetailsBuilder> = {
raw: (stream) => { raw: (stream) => {
let capTmplName = ''; const chips: ModChipOpts[] = [];
if (stream.capture_template_id) { if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) capTmplName = escapeHtml(capTmpl.name); if (capTmpl) {
chips.push({
icon: ICON_CAPTURE_TEMPLATE,
text: capTmpl.name,
title: t('streams.capture_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')`,
});
}
} }
return `<div class="stream-card-props"> const metaParts = [
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span> `${t('streams.display')} ${stream.display_index ?? 0}`,
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span> `${stream.target_fps ?? 30} fps`,
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''} ];
</div>`; return {
badgeText: 'SCREEN · IN',
metaHtml: metaParts.map(escapeHtml).join(' · '),
chips,
};
}, },
processed: (stream) => { processed: (stream) => {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); const sourceName = sourceStream ? sourceStream.name : (stream.source_stream_id || '-');
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw'; const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams'; const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
let ppTmplName = ''; const chips: ModChipOpts[] = [];
chips.push({
icon: ICON_LINK_SOURCE,
text: sourceName,
title: t('streams.source'),
onclick: stream.source_stream_id
? `event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')`
: undefined,
});
if (stream.postprocessing_template_id) { if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name); if (ppTmpl) {
chips.push({
icon: ICON_PP_TEMPLATE,
text: ppTmpl.name,
title: t('streams.pp_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')`,
});
}
} }
return `<div class="stream-card-props"> return {
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span> badgeText: 'PIC · OUT',
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''} metaHtml: chips.length > 1 ? `${chips.length} ${escapeHtml(t('streams.pp_template') || 'filters')}` : escapeHtml(t('streams.source') || 'Source'),
</div>`; chips,
};
}, },
static_image: (stream) => { static_image: (stream) => {
const assetName = _getAssetName(stream.image_asset_id); const assetName = _getAssetName(stream.image_asset_id);
return `<div class="stream-card-props"> return {
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span> badgeText: 'IMG · IN',
</div>`; metaHtml: escapeHtml(assetName),
chips: [{ icon: ICON_ASSET, text: assetName, title: assetName }],
};
}, },
video: (stream) => { video: (stream) => {
const assetName = _getAssetName(stream.video_asset_id); const assetName = _getAssetName(stream.video_asset_id);
return `<div class="stream-card-props"> const chips: ModChipOpts[] = [
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span> { icon: ICON_ASSET, text: assetName, title: assetName },
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span> { icon: ICON_FPS, text: `${stream.target_fps ?? 30} fps`, title: t('streams.target_fps') },
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''} ];
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''} if (stream.loop !== false) chips.push({ text: '↻ loop' });
</div>`; if (stream.playback_speed && stream.playback_speed !== 1.0) chips.push({ text: `${stream.playback_speed}× speed` });
return {
badgeText: 'VIDEO · IN',
metaHtml: escapeHtml(assetName),
chips,
};
}, },
}; };
@@ -396,138 +439,189 @@ function renderPictureSourcesList(streams: any) {
const activeTab = getActiveSubTab('streams')!; const activeTab = getActiveSubTab('streams')!;
const renderStreamCard = (stream: any) => { const renderStreamCard = (stream: any) => {
const typeIcon = getPictureSourceIcon(stream.stream_type); const builder = PICTURE_SOURCE_CARD_DETAILS[stream.stream_type];
const details = builder ? builder(stream)
: { badgeText: 'PIC · IN', metaHtml: '', chips: [] };
const renderer = PICTURE_SOURCE_CARD_RENDERERS[stream.stream_type]; const sectionKey = stream.stream_type === 'static_image' ? 'static-streams'
const detailsHtml = renderer ? renderer(stream) : ''; : stream.stream_type === 'video' ? 'video-streams'
: stream.stream_type === 'processed' ? 'proc-streams'
: 'raw-streams';
return wrapCard({ const mod: ModCardOpts = {
type: 'template-card', head: {
dataAttr: 'data-stream-id', badge: { text: details.badgeText },
id: stream.id, name: stream.name,
removeOnclick: `deleteStream('${stream.id}')`, metaHtml: details.metaHtml,
removeTitle: t('common.delete'), leds: ['off'],
content: ` menu: {
<div class="template-card-header"> duplicateOnclick: `cloneStream('${stream.id}')`,
<div class="template-name" title="${escapeHtml(stream.name)}">${typeIcon} ${escapeHtml(stream.name)}</div> hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`,
</div> deleteOnclick: `deleteStream('${stream.id}')`,
${detailsHtml} },
${renderTagChips(stream.tags)} },
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`, body: {
actions: ` desc: stream.description || undefined,
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button> chips: details.chips.length ? details.chips : undefined,
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> },
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, foot: {
}); patchState: 'idle',
patchLabel: 'SOURCE',
iconActions: [
{ icon: ICON_TEST, onclick: `showTestStreamModal('${stream.id}')`, title: t('streams.test.title') },
{ icon: ICON_EDIT, onclick: `editStream('${stream.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-stream-id', id: stream.id, mod });
const tagsHtml = renderTagChips(stream.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
const renderCaptureTemplateCard = (template: any) => { const renderCaptureTemplateCard = (template: any) => {
const engineIcon = getEngineIcon(template.engine_type); const configEntries = Object.entries(template.engine_config || {});
const configEntries = Object.entries(template.engine_config); const chips: ModChipOpts[] = [
return wrapCard({ { icon: getEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('templates.engine') },
type: 'template-card', ];
dataAttr: 'data-template-id', if (configEntries.length > 0) {
id: template.id, chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('templates.config.show') || 'config')}`, title: t('templates.config.show') });
removeOnclick: `deleteTemplate('${template.id}')`, }
removeTitle: t('common.delete'), const configBlock = configEntries.length > 0 ? `
content: ` <div class="template-config-collapse">
<div class="template-card-header"> <button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div> <div class="template-config-animate">
</div> <div class="template-config-inner">
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''} <table class="config-table">
<div class="stream-card-props"> ${configEntries.map(([key, val]) => `
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span> <tr>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''} <td class="config-key">${escapeHtml(key)}</td>
</div> <td class="config-value">${escapeHtml(String(val))}</td>
${renderTagChips(template.tags)} </tr>
${configEntries.length > 0 ? ` `).join('')}
<div class="template-config-collapse"> </table>
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div> </div>
` : ''}`, </div>
actions: ` </div>` : '';
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> const mod: ModCardOpts = {
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, head: {
}); badge: { text: 'TPL · CAPTURE' },
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
menu: {
duplicateOnclick: `cloneCaptureTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`,
deleteOnclick: `deleteTemplate('${template.id}')`,
},
},
body: {
desc: template.description || undefined,
chips,
extraHtml: configBlock || undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'TEMPLATE',
iconActions: [
{ icon: ICON_TEST, onclick: `showTestTemplateModal('${template.id}')`, title: t('templates.test.title') },
{ icon: ICON_EDIT, onclick: `editTemplate('${template.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-template-id', id: template.id, mod });
const tagsHtml = renderTagChips(template.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
const renderPPTemplateCard = (tmpl: any) => { const renderPPTemplateCard = (tmpl: any) => {
let filterChainHtml = ''; const filters = tmpl.filters || [];
if (tmpl.filters && tmpl.filters.length > 0) { const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
const filterNames = tmpl.filters.map(fi => { filters.map((fi: any, idx: number) => {
let label = _getFilterName(fi.filter_id); let label = _getFilterName(fi.filter_id);
if (fi.filter_id === 'filter_template' && fi.options?.template_id) { if (fi.filter_id === 'filter_template' && fi.options?.template_id) {
const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id); const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`; if (ref) label += `: ${ref.name}`;
} }
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`; const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">→</span>' : '';
}); return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`; }).join('')
} }</div>` : '';
return wrapCard({
type: 'template-card', const mod: ModCardOpts = {
dataAttr: 'data-pp-template-id', head: {
id: tmpl.id, badge: { text: 'TPL · FILTER' },
removeOnclick: `deletePPTemplate('${tmpl.id}')`, name: tmpl.name,
removeTitle: t('common.delete'), metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
content: ` leds: ['off'],
<div class="template-card-header"> menu: {
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div> duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
</div> hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''} deleteOnclick: `deletePPTemplate('${tmpl.id}')`,
${filterChainHtml} },
${renderTagChips(tmpl.tags)}`, },
actions: ` body: {
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button> desc: tmpl.description || undefined,
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> extraHtml: chainExtra || undefined,
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, },
}); foot: {
patchState: 'idle',
patchLabel: 'PIPELINE',
iconActions: [
{ icon: ICON_TEST, onclick: `showTestPPTemplateModal('${tmpl.id}')`, title: t('postprocessing.test.title') },
{ icon: ICON_EDIT, onclick: `editPPTemplate('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-pp-template-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
const renderCSPTCard = (tmpl: any) => { const renderCSPTCard = (tmpl: any) => {
let filterChainHtml = ''; const filters = tmpl.filters || [];
if (tmpl.filters && tmpl.filters.length > 0) { const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
const filterNames = tmpl.filters.map(fi => { filters.map((fi: any, idx: number) => {
let label = _getStripFilterName(fi.filter_id); let label = _getStripFilterName(fi.filter_id);
if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) { if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) {
const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id); const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`; if (ref) label += `: ${ref.name}`;
} }
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`; const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
}); return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`; }).join('')
} }</div>` : '';
return wrapCard({
type: 'template-card', const mod: ModCardOpts = {
dataAttr: 'data-cspt-id', head: {
id: tmpl.id, badge: { text: 'TPL \u00b7 STRIP' },
removeOnclick: `deleteCSPT('${tmpl.id}')`, name: tmpl.name,
removeTitle: t('common.delete'), metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
content: ` leds: ['off'],
<div class="template-card-header"> menu: {
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div> duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
</div> hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''} deleteOnclick: `deleteCSPT('${tmpl.id}')`,
${filterChainHtml} },
${renderTagChips(tmpl.tags)}`, },
actions: ` body: {
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button> desc: tmpl.description || undefined,
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> extraHtml: chainExtra || undefined,
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, },
}); foot: {
patchState: 'idle',
patchLabel: 'PIPELINE',
iconActions: [
{ icon: ICON_TEST, onclick: `event.stopPropagation(); testCSPT('${tmpl.id}')`, title: t('color_strip.test.title') },
{ icon: ICON_EDIT, onclick: `editCSPT('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-cspt-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
const rawStreams = streams.filter(s => s.stream_type === 'raw'); const rawStreams = streams.filter(s => s.stream_type === 'raw');
@@ -646,120 +740,178 @@ function renderPictureSourcesList(streams: any) {
}; };
const renderAudioSourceCard = (src: any) => { const renderAudioSourceCard = (src: any) => {
const icon = getAudioSourceIcon(src.source_type); const chips: ModChipOpts[] = [];
let badgeText: string;
let metaText: string;
let propsHtml = '';
if (src.source_type === 'processed') { if (src.source_type === 'processed') {
badgeText = 'AUDIO · FX';
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : src.audio_source_id; const parentName = parent ? parent.name : (src.audio_source_id || '—');
const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture'; const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture';
const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture'; const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture';
const parentBadge = parent chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')">${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}</span>` icon: parent ? getAudioSourceIcon(parent.source_type) : ICON_AUDIO_LOOPBACK,
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`; text: parentName,
propsHtml = `${parentBadge}`; title: t('audio_source.parent'),
onclick: parent
? `event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')`
: undefined,
});
if (src.audio_processing_template_id) { if (src.audio_processing_template_id) {
const aptTmpl = _cachedAudioProcessingTemplates.find(t => t.id === src.audio_processing_template_id); const aptTmpl = _cachedAudioProcessingTemplates.find(tt => tt.id === src.audio_processing_template_id);
const aptName = aptTmpl ? escapeHtml(aptTmpl.name) : escapeHtml(src.audio_processing_template_id); const aptName = aptTmpl ? aptTmpl.name : src.audio_processing_template_id;
propsHtml += aptTmpl chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_processing.title'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')">${ICON_AUDIO_TEMPLATE} ${aptName}</span>` icon: ICON_AUDIO_TEMPLATE,
: `<span class="stream-card-prop">${ICON_AUDIO_TEMPLATE} ${aptName}</span>`; text: aptName,
title: t('audio_processing.title'),
onclick: aptTmpl
? `event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')`
: undefined,
});
} }
metaText = parent ? `via ${parentName}` : 'orphan source';
} else { } else {
// Capture source
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false; const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`; const devIdx = src.device_index ?? -1;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null; badgeText = loopback ? 'LOOP · IN' : 'MIC · IN';
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>` : ''; const devLabel = loopback ? 'Loopback' : 'Input';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`; metaText = `${devLabel} #${devIdx}`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(tt => tt.id === src.audio_template_id) : null;
if (tpl) {
chips.push({
icon: ICON_AUDIO_TEMPLATE,
text: tpl.name,
title: t('audio_source.audio_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')`,
});
}
} }
return wrapCard({ const sectionKey = src.source_type === 'processed' ? 'audio-processed' : 'audio-capture';
type: 'template-card', const mod: ModCardOpts = {
dataAttr: 'data-id', head: {
id: src.id, badge: { text: badgeText },
removeOnclick: `deleteAudioSource('${src.id}')`, name: src.name,
removeTitle: t('common.delete'), metaHtml: escapeHtml(metaText),
content: ` leds: ['off'],
<div class="template-card-header"> menu: {
<div class="template-name" title="${escapeHtml(src.name)}">${icon} ${escapeHtml(src.name)}</div> duplicateOnclick: `cloneAudioSource('${src.id}')`,
</div> hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''} deleteOnclick: `deleteAudioSource('${src.id}')`,
<div class="stream-card-props">${propsHtml}</div> },
${renderTagChips(src.tags)}`, },
actions: ` body: {
<button class="btn btn-icon btn-secondary" data-action="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</button> desc: src.description || undefined,
<button class="btn btn-icon btn-secondary" data-action="clone-audio" title="${t('common.clone')}">${ICON_CLONE}</button> chips: chips.length ? chips : undefined,
<button class="btn btn-icon btn-secondary" data-action="edit-audio" title="${t('common.edit')}">${ICON_EDIT}</button>`, },
}); foot: {
patchState: 'idle',
patchLabel: 'SOURCE',
iconActions: [
{ icon: ICON_TEST, onclick: '', title: t('audio_source.test'), dataAttrs: { 'data-action': 'test-audio' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit-audio' } },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod });
const tagsHtml = renderTagChips(src.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
const renderAudioTemplateCard = (template: any) => { const renderAudioTemplateCard = (template: any) => {
const configEntries = Object.entries(template.engine_config || {}); const configEntries = Object.entries(template.engine_config || {});
return wrapCard({ const chips: ModChipOpts[] = [
type: 'template-card', { icon: getAudioEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('audio_template.engine') },
dataAttr: 'data-audio-template-id', ];
id: template.id, if (configEntries.length > 0) {
removeOnclick: `deleteAudioTemplate('${template.id}')`, chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('audio_template.config.show') || 'config')}`, title: t('audio_template.config.show') });
removeTitle: t('common.delete'), }
content: ` const configBlock = configEntries.length > 0 ? `
<div class="template-card-header"> <div class="template-config-collapse">
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div> <button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
</div> <div class="template-config-animate">
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''} <div class="template-config-inner">
<div class="stream-card-props"> <table class="config-table">
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span> ${configEntries.map(([key, val]) => `
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''} <tr>
</div> <td class="config-key">${escapeHtml(key)}</td>
${renderTagChips(template.tags)} <td class="config-value">${escapeHtml(String(val))}</td>
${configEntries.length > 0 ? ` </tr>
<div class="template-config-collapse"> `).join('')}
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button> </table>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div> </div>
` : ''}`, </div>
actions: ` </div>` : '';
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> const mod: ModCardOpts = {
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, head: {
}); badge: { text: 'TPL · AUDIO' },
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
menu: {
duplicateOnclick: `cloneAudioTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`,
deleteOnclick: `deleteAudioTemplate('${template.id}')`,
},
},
body: {
desc: template.description || undefined,
chips,
extraHtml: configBlock || undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'TEMPLATE',
iconActions: [
{ icon: ICON_TEST, onclick: `showTestAudioTemplateModal('${template.id}')`, title: t('audio_template.test') },
{ icon: ICON_EDIT, onclick: `editAudioTemplate('${template.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-audio-template-id', id: template.id, mod });
const tagsHtml = renderTagChips(template.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}; };
// Gradient card renderer // Gradient card renderer
const renderGradientCard = (g: GradientEntity) => { const renderGradientCard = (g: GradientEntity) => {
const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
const stripPreview = `<div style="height:24px;border-radius:4px;background:linear-gradient(to right,${cssStops});margin-bottom:6px"></div>`; // The `.mod-preview` wrapper inside renderModBody doesn't accept
const lockBadge = g.is_builtin ? `<span class="badge badge-info" style="font-size:0.7em;margin-left:4px">${t('gradient.builtin')}</span>` : ''; // inline style, so emit a sibling block via `extraHtml` so the
const cloneBtn = `<button class="btn btn-icon btn-secondary" onclick="cloneGradient('${g.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>`; // gradient fills the full preview surface.
const editBtn = g.is_builtin ? '' : `<button class="btn btn-icon btn-secondary" onclick="editGradient('${g.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`; const previewBlock = `<div class="mod-preview mod-preview--strip" style="height:36px;background:linear-gradient(to right,${cssStops});">${
return wrapCard({ g.is_builtin ? `<span class="mod-preview__tag">${escapeHtml(t('gradient.builtin') || 'BUILTIN').toUpperCase()}</span>` : ''
type: 'template-card', }</div>`;
dataAttr: 'data-id', const iconActions: any[] = [
id: g.id, { icon: ICON_CLONE, onclick: `cloneGradient('${g.id}')`, title: t('common.clone') },
removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`, ];
removeTitle: t('common.delete'), if (!g.is_builtin) {
content: ` iconActions.push({ icon: ICON_EDIT, onclick: `editGradient('${g.id}')`, title: t('common.edit') });
<div class="template-card-header"> }
<div class="template-name">${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}</div> const mod: ModCardOpts = {
</div> head: {
${stripPreview} badge: { text: 'PALETTE · GRD' },
<div class="stream-card-props"> name: g.name,
<span class="stream-card-prop">${g.stops.length} ${t('gradient.stops_label')}</span> metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
</div>`, leds: ['off'],
actions: `${cloneBtn}${editBtn}`, menu: {
}); duplicateOnclick: `cloneGradient('${g.id}')`,
hideOnclick: `toggleCardHidden('gradients','${g.id}')`,
deleteOnclick: g.is_builtin ? undefined : `deleteGradient('${g.id}')`,
},
},
body: {
extraHtml: previewBlock,
},
foot: {
patchState: 'idle',
patchLabel: 'PRESET',
iconActions,
},
};
return wrapCard({ type: 'template-card', dataAttr: 'data-id', id: g.id, mod });
}; };
// Build item arrays for all sections // Build item arrays for all sections
@@ -9,6 +9,7 @@ import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.ts'; import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts'; import { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.ts'; import type { SyncClock } from '../types.ts';
@@ -223,35 +224,51 @@ function _formatElapsed(seconds: number): string {
} }
export function createSyncClockCard(clock: SyncClock) { export function createSyncClockCard(clock: SyncClock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE; const isRunning = !!clock.is_running;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused'); const statusLabel = isRunning ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running ? 'pause' : 'resume'; const toggleAction = isRunning ? 'pause' : 'resume';
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); const toggleTitle = isRunning ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null; const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
return wrapCard({ const chips: ModChipOpts[] = [
type: 'template-card', { icon: ICON_CLOCK, text: `${clock.speed}x` },
dataAttr: 'data-id', ];
id: clock.id, if (elapsedLabel) {
removeOnclick: `deleteSyncClock('${clock.id}')`, chips.push({ text: `${elapsedLabel}`, title: t('sync_clock.elapsed') });
removeTitle: t('common.delete'), }
content: `
<div class="template-card-header"> const leds: LedState[] = isRunning ? ['on', 'blink'] : ['off'];
<div class="template-name">${ICON_CLOCK} ${escapeHtml(clock.name)}</div>
</div> const mod: ModCardOpts = {
<div class="stream-card-props"> head: {
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span> badge: { text: 'CLK · MASTER' },
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span> name: clock.name,
${elapsedLabel ? `<span class="stream-card-prop" title="${t('sync_clock.elapsed')}">⏱ ${elapsedLabel}</span>` : ''} metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`),
</div> leds,
${renderTagChips(clock.tags)} menu: {
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`, duplicateOnclick: `cloneSyncClock('${clock.id}')`,
actions: ` hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`,
<button class="btn btn-icon btn-secondary" data-action="${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button> deleteOnclick: `deleteSyncClock('${clock.id}')`,
<button class="btn btn-icon btn-secondary" data-action="reset" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button> },
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button> },
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`, body: {
}); desc: clock.description || undefined,
chips,
},
foot: {
patchState: isRunning ? 'live' : 'idle',
patchLabel: isRunning ? 'TICKING' : 'PAUSED',
iconActions: [
{ icon: isRunning ? ICON_PAUSE : ICON_START, onclick: '', title: toggleTitle, dataAttrs: { 'data-action': toggleAction } },
{ icon: ICON_CLOCK, onclick: '', title: t('sync_clock.action.reset'), dataAttrs: { 'data-action': 'reset' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
],
},
running: isRunning,
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: clock.id, mod });
const tagsHtml = renderTagChips(clock.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Event delegation for sync-clock card actions ── // ── Event delegation for sync-clock card actions ──
+534 -10
View File
@@ -10,6 +10,12 @@ import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
// ─── State ────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────
interface UpdateAsset {
name: string;
size: number;
download_url: string;
}
interface UpdateRelease { interface UpdateRelease {
version: string; version: string;
tag: string; tag: string;
@@ -17,6 +23,7 @@ interface UpdateRelease {
body: string; body: string;
prerelease: boolean; prerelease: boolean;
published_at: string; published_at: string;
assets?: UpdateAsset[];
} }
interface UpdateStatus { interface UpdateStatus {
@@ -179,7 +186,15 @@ function _applyStatus(status: UpdateStatus): void {
&& status.release != null && status.release != null
&& status.release.version !== dismissed; && status.release.version !== dismissed;
// The header badge + floating banner respect the user's dismissal so they
// don't keep nagging on every page reload. The rail badge inside the
// settings modal is the *destination* the user is told to visit — it
// should keep flagging the update even after dismissal so they can find
// the Updates tab again. Hide it only when there's genuinely no update.
const hasAnyUpdate = status.has_update && status.release != null;
_setVersionBadgeUpdate(hasVisibleUpdate); _setVersionBadgeUpdate(hasVisibleUpdate);
_setRailUpdateBadge(hasAnyUpdate, status.release?.version ?? '');
if (hasVisibleUpdate) { if (hasVisibleUpdate) {
_showBanner(status); _showBanner(status);
@@ -190,6 +205,24 @@ function _applyStatus(status: UpdateStatus): void {
_renderUpdatePanel(status); _renderUpdatePanel(status);
} }
/** Toggle the small badge on the Updates rail-button so the sidebar
* reflects "update available" the same way the in-app version badge does.
* Runs on every status fetch / WS event so the indicator stays in sync. */
function _setRailUpdateBadge(visible: boolean, version: string): void {
const badge = document.getElementById('settings-rail-update-badge');
if (!badge) return;
badge.hidden = !visible;
if (visible) {
// Numeric "1" reads cleaner than the version string in the small pill.
badge.textContent = '1';
if (version) {
badge.setAttribute('title', t('update.available').replace('{version}', version));
}
} else {
badge.removeAttribute('title');
}
}
// ─── WS event handlers ───────────────────────────────────── // ─── WS event handlers ─────────────────────────────────────
export function initUpdateListener(): void { export function initUpdateListener(): void {
@@ -282,6 +315,8 @@ function _getChannelItems(): { value: string; icon: string; label: string; desc:
} }
export function initUpdateSettingsPanel(): void { export function initUpdateSettingsPanel(): void {
// The IconSelects auto-persist on change — there is no longer a manual
// Save button in the Auto-Check section.
if (!_channelIconSelect) { if (!_channelIconSelect) {
const sel = document.getElementById('update-channel') as HTMLSelectElement | null; const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
if (sel) { if (sel) {
@@ -289,6 +324,7 @@ export function initUpdateSettingsPanel(): void {
target: sel, target: sel,
items: _getChannelItems(), items: _getChannelItems(),
columns: 2, columns: 2,
onChange: () => saveUpdateSettings(),
}); });
} }
} }
@@ -299,9 +335,16 @@ export function initUpdateSettingsPanel(): void {
target: sel, target: sel,
items: _getIntervalItems(), items: _getIntervalItems(),
columns: 3, columns: 3,
onChange: () => saveUpdateSettings(),
}); });
} }
} }
// Toggle auto-saves on every flip.
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
if (enabledEl && !enabledEl.dataset.autosaveBound) {
enabledEl.addEventListener('change', () => { saveUpdateSettings(); });
enabledEl.dataset.autosaveBound = '1';
}
} }
export async function loadUpdateSettings(): Promise<void> { export async function loadUpdateSettings(): Promise<void> {
@@ -331,12 +374,17 @@ export async function loadUpdateSettings(): Promise<void> {
await loadUpdateStatus(); await loadUpdateStatus();
} }
/** Persist auto-check settings. The Save button has been removed
* this is invoked silently from change-listeners on the three fields
* (enabled toggle, interval IconSelect, channel IconSelect).
* Errors still surface as toasts; success is silent. */
export async function saveUpdateSettings(): Promise<void> { export async function saveUpdateSettings(): Promise<void> {
const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true; const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24'; const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
const check_interval_hours = parseFloat(intervalStr); const check_interval_hours = parseFloat(intervalStr);
const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false'; const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
const include_prerelease = channelVal === 'true'; const include_prerelease = channelVal === 'true';
if (Number.isNaN(check_interval_hours)) return;
try { try {
const resp = await fetchWithAuth('/system/update/settings', { const resp = await fetchWithAuth('/system/update/settings', {
@@ -347,7 +395,6 @@ export async function saveUpdateSettings(): Promise<void> {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('update.settings_saved'), 'success');
} catch (err) { } catch (err) {
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error'); showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
} }
@@ -358,16 +405,36 @@ function _renderUpdatePanel(status: UpdateStatus): void {
if (currentEl) currentEl.textContent = `v${status.current_version}`; if (currentEl) currentEl.textContent = `v${status.current_version}`;
const statusEl = document.getElementById('update-status-text'); const statusEl = document.getElementById('update-status-text');
const card = document.getElementById('update-status-card');
const meta = document.getElementById('update-status-meta');
let state: 'available' | 'error' | 'updated' | 'checking' = 'updated';
let metaText = '';
if (statusEl) { if (statusEl) {
if (status.has_update && status.release) { if (status.has_update && status.release) {
statusEl.textContent = t('update.available').replace('{version}', status.release.version); statusEl.textContent = t('update.available').replace('{version}', status.release.version);
statusEl.style.color = 'var(--warning-color)'; statusEl.style.color = '';
state = 'available';
metaText = t('update.pill.available');
} else if (status.last_error) { } else if (status.last_error) {
statusEl.textContent = t('update.check_error') + ': ' + status.last_error; statusEl.textContent = t('update.check_error') + ': ' + status.last_error;
statusEl.style.color = 'var(--danger-color)'; statusEl.style.color = '';
state = 'error';
metaText = t('update.pill.error');
} else { } else {
statusEl.textContent = t('update.up_to_date'); statusEl.textContent = t('update.up_to_date');
statusEl.style.color = 'var(--primary-color)'; statusEl.style.color = '';
state = 'updated';
metaText = t('update.pill.updated');
}
}
if (card) card.setAttribute('data-state', state);
if (meta) {
if (metaText) {
meta.textContent = metaText;
meta.hidden = false;
} else {
meta.hidden = true;
} }
} }
@@ -424,15 +491,472 @@ function _renderUpdatePanel(status: UpdateStatus): void {
// ─── Release Notes Overlay ───────────────────────────────── // ─── Release Notes Overlay ─────────────────────────────────
function _setRnText(id: string, text: string | null): void {
const el = document.getElementById(id);
if (!el) return;
if (text) {
el.textContent = text;
el.hidden = false;
} else {
el.textContent = '';
el.hidden = true;
}
}
function _setRnChipShown(id: string, shown: boolean): void {
const el = document.getElementById(id);
if (el) el.hidden = !shown;
}
function _formatPublished(iso: string | null | undefined): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
// Compact ISO-like form: YYYY-MM-DD (matches the mono "instrument" tone).
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/** Populate the redesigned overlay header from the cached release status. */
function _renderReleaseNotesHeader(): void {
const release = _lastStatus?.release ?? null;
const name = release?.name?.trim() || '';
const version = release?.version?.trim() || '';
const nameEl = document.getElementById('release-notes-name');
if (nameEl) {
nameEl.textContent = name || t('update.release_notes');
}
// Only show the version accent when the name doesn't already contain
// the version — avoids rendering "LedGrab v0.5.0 v0.5.0".
const nameLower = name.toLowerCase();
const verLower = version.toLowerCase();
const nameHasVersion = !!verLower && (
nameLower.includes(verLower) || nameLower.includes('v' + verLower)
);
if (version && !nameHasVersion) {
_setRnText('release-notes-version', `v${version}`);
} else {
_setRnText('release-notes-version', null);
}
const tag = release?.tag?.trim() || null;
_setRnText('release-notes-tag', tag);
_setRnChipShown('release-notes-tag-chip', !!tag);
const dateText = _formatPublished(release?.published_at);
_setRnText('release-notes-date', dateText);
_setRnChipShown('release-notes-date-chip', !!dateText);
_setRnChipShown('release-notes-pre-chip', !!release?.prerelease);
const metaWrap = document.getElementById('release-notes-meta');
if (metaWrap) {
const anyChipShown = !!tag || !!dateText || !!release?.prerelease;
metaWrap.hidden = !anyChipShown;
}
// External link to the release on the upstream tracker (if available).
const ext = document.getElementById('release-notes-external') as HTMLAnchorElement | null;
if (ext) {
const url = _lastStatus?.releases_url;
if (url) {
ext.href = url;
ext.hidden = false;
} else {
ext.removeAttribute('href');
ext.hidden = true;
}
}
}
/** File extensions we recognise as direct downloads in markdown links. */
const _RN_ASSET_EXTS = new Set([
'exe', 'msi', 'dmg', 'pkg', 'zip', 'tgz', 'tbz', '7z', 'rar',
'apk', 'aab', 'ipa', 'deb', 'rpm', 'appimage', 'iso', 'img',
'sig', 'asc', 'jar', 'whl', 'gz', 'bz2', 'xz',
]);
interface AssetClass { type: 'asset'; ext: string }
interface ExternalClass { type: 'external' }
type LinkClass = AssetClass | ExternalClass | null;
/** Classify a markdown link as a download asset, an external link, or
* neither (in-page anchor / same-origin page link). */
function _classifyReleaseLink(href: string): LinkClass {
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:')) {
return null;
}
let url: URL;
try {
url = new URL(href, location.origin);
} catch {
return null;
}
const path = url.pathname;
const lower = path.toLowerCase();
// GitHub / Gitea release-asset and attachment paths are always downloads,
// even if the extension isn't one we recognise (e.g. unsigned binaries).
if (lower.includes('/releases/download/') || lower.includes('/attachments/')) {
const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/);
if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] };
const m = lower.match(/\.([a-z0-9]{1,8})$/);
return { type: 'asset', ext: m ? m[1] : 'bin' };
}
// Compound tar archives — must be checked before single-extension match.
const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/);
if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] };
// Single-extension downloadable file.
const m = lower.match(/\.([a-z0-9]+)$/);
if (m && _RN_ASSET_EXTS.has(m[1])) {
return { type: 'asset', ext: m[1] };
}
// Anything off-origin we treat as external (gets a small ↗ glyph).
if (url.origin && url.origin !== location.origin) {
return { type: 'external' };
}
return null;
}
/** Walk `<a>` tags inside the rendered markdown and decorate each one
* according to its classification: download assets become rack-style
* chips with a download icon and an extension badge; external links
* get a small marker. Idempotent safe to call multiple times. */
function _decorateReleaseNotesLinks(root: HTMLElement): void {
const anchors = root.querySelectorAll<HTMLAnchorElement>('a[href]');
anchors.forEach((a) => {
if (a.classList.contains('rn-dl') || a.classList.contains('rn-ext')) {
return; // already decorated
}
const cls = _classifyReleaseLink(a.getAttribute('href') || '');
if (!cls) return;
if (cls.type === 'asset') {
a.classList.add('rn-dl');
// Hint to the browser to download rather than navigate.
if (!a.hasAttribute('download')) a.setAttribute('download', '');
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
const icon = document.createElement('span');
icon.className = 'rn-dl__icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = ICON_DOWNLOAD;
a.prepend(icon);
const badge = document.createElement('span');
badge.className = 'rn-dl__ext';
badge.textContent = cls.ext;
a.appendChild(badge);
} else {
a.classList.add('rn-ext');
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
}
});
}
/** Pretty-print a byte count as a compact human-readable string
* (`24.3 MB`, `512 KB`, `7 B`). Tabular-friendly single decimal. */
function _formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return (i === 0 ? n.toFixed(0) : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + units[i];
}
/** Extract the file extension (incl. compound `tar.gz/bz2/xz`) from a name. */
function _assetExt(name: string): string {
const lower = name.toLowerCase();
const tar = lower.match(/\.tar\.(gz|bz2|xz)$/);
if (tar) return 'tar.' + tar[1];
const m = lower.match(/\.([a-z0-9]{1,8})$/);
return m ? m[1] : 'bin';
}
/** Build a `<span>` with a class and text content. */
function _span(cls: string, text?: string): HTMLSpanElement {
const el = document.createElement('span');
el.className = cls;
if (text !== undefined) el.textContent = text;
return el;
}
/** Validate a download URL reject anything that isn't http(s) so a
* malicious upstream release can't smuggle a `javascript:` URL. */
function _safeDownloadHref(raw: string): string | null {
try {
const u = new URL(raw, location.origin);
if (u.protocol === 'http:' || u.protocol === 'https:') {
return u.toString();
}
} catch {
return null;
}
return null;
}
/** Compute the trailing file extension (incl. compound `.tar.gz/.bz2/.xz`)
* on a filename string, or '' if there isn't one. Lower-cased. */
function _extOf(name: string): string {
const lower = name.toLowerCase();
const tar = lower.match(/\.tar\.(gz|bz2|xz)$/);
if (tar) return '.tar.' + tar[1];
const m = lower.match(/\.[a-z0-9]+$/);
return m ? m[0] : '';
}
/** Tokenise a filename for fuzzy matching: split on `-_.\s` and keep
* tokens of length 2 (drops the version segment to noise tokens). */
function _fileTokens(name: string): Set<string> {
return new Set(
name.toLowerCase()
.split(/[-_.\s]+/)
.filter((t) => t.length >= 2),
);
}
/** Find the asset whose name best matches a filename string from the
* release-notes markdown. Returns the asset on exact match, or on a
* same-extension fuzzy match where the user-supplied tokens cover 60%
* of the asset's tokens (handles "LedGrab-v0.5.0-setup.exe" the
* full "LedGrab-v0.5.0-win-x64-setup.exe" asset). */
function _findMatchingAsset(text: string, assets: UpdateAsset[]): UpdateAsset | null {
const lower = text.trim().toLowerCase();
if (!lower || !lower.includes('.')) return null;
// 1. Exact name match (basename only — strip any path component).
const basename = lower.replace(/^.*[\\/]/, '');
for (const a of assets) {
if (a.name.toLowerCase() === basename) return a;
}
// 2. Fuzzy: same extension + significant token overlap.
const ext = _extOf(basename);
if (!ext) return null;
const userTokens = _fileTokens(basename);
if (userTokens.size === 0) return null;
let best: UpdateAsset | null = null;
let bestScore = 0;
for (const a of assets) {
const aLower = a.name.toLowerCase();
if (!aLower.endsWith(ext)) continue;
const aTokens = _fileTokens(a.name);
if (aTokens.size === 0) continue;
let overlap = 0;
userTokens.forEach((t) => { if (aTokens.has(t)) overlap += 1; });
// Score by how many of the user's tokens the asset covers.
const score = overlap / userTokens.size;
if (score > bestScore) {
bestScore = score;
best = a;
}
}
return bestScore >= 0.6 ? best : null;
}
/** Wrap any inline `<code>` element whose text matches a release asset
* in an `<a>` so the file becomes clickable. Skips code blocks (`<pre>`)
* and codes already inside an `<a>`. Idempotent. */
function _linkInlineAssetCode(root: HTMLElement): void {
const assets = (_lastStatus?.release?.assets ?? []).filter(
(a) => _safeDownloadHref(a.download_url) !== null,
);
if (assets.length === 0) return;
const codes = root.querySelectorAll<HTMLElement>('code');
codes.forEach((code) => {
if (code.closest('pre')) return; // skip code-fence blocks
if (code.closest('a')) return; // already linked
const text = code.textContent || '';
const asset = _findMatchingAsset(text, assets);
if (!asset) return;
const href = _safeDownloadHref(asset.download_url);
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.setAttribute('download', '');
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.classList.add('rn-code-link');
// Build a richer tooltip: filename · description · size, when available.
const sizeText = _formatBytes(asset.size);
const descText = _describeAsset(asset.name);
const tooltipParts = [asset.name];
if (descText) tooltipParts.push(descText);
if (sizeText) tooltipParts.push(sizeText);
a.title = tooltipParts.join(' · ');
const parent = code.parentNode;
if (!parent) return;
parent.insertBefore(a, code);
a.appendChild(code);
});
}
/** Recognise checksum / signature side-files. These are 100-byte text
* artifacts paired with a binary, useful only for power-user
* verification they make the rack noisy without adding download
* value, so we hide them. */
function _isChecksumAsset(name: string): boolean {
const lower = name.toLowerCase();
return /\.(sha256|sha512|sha1|sha384|md5|sig|asc|sigstore|sbom|json\.sig)$/.test(lower)
|| /\.(sha256|sha512|sha1|md5)\.txt$/.test(lower);
}
/** Derive a short human description for an asset from its filename
* pattern (installer / portable / archive / mobile build). Falls back
* to '' when nothing recognisable matches the description line is
* then hidden. Strings come from the locale bundle. */
function _describeAsset(name: string): string {
const lower = name.toLowerCase();
// Windows family
if (/setup\.exe$/.test(lower)) return t('update.assets.desc.windows_installer');
if (/\.msi$/.test(lower)) return t('update.assets.desc.windows_msi');
if (/\.exe$/.test(lower) && /\bwin/.test(lower)) {
return t('update.assets.desc.windows_exe');
}
if (/\.zip$/.test(lower) && /\bwin/.test(lower)) {
return t('update.assets.desc.windows_portable');
}
// Linux family
if (/\.appimage$/.test(lower)) return t('update.assets.desc.linux_appimage');
if (/\.deb$/.test(lower)) return t('update.assets.desc.linux_deb');
if (/\.rpm$/.test(lower)) return t('update.assets.desc.linux_rpm');
if (/\.flatpak$/.test(lower) || /\.snap$/.test(lower)) {
return t('update.assets.desc.linux_sandbox');
}
if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower) && /linux/.test(lower)) {
return t('update.assets.desc.linux_tarball');
}
// macOS family
if (/\.dmg$/.test(lower)) return t('update.assets.desc.macos_dmg');
if (/\.pkg$/.test(lower)) return t('update.assets.desc.macos_installer');
// Android / iOS
if (/\.apk$/.test(lower)) return t('update.assets.desc.android');
if (/\.aab$/.test(lower)) return t('update.assets.desc.android_bundle');
if (/\.ipa$/.test(lower)) return t('update.assets.desc.ios');
// Generic archive fallbacks
if (/\.zip$/.test(lower)) return t('update.assets.desc.zip_archive');
if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower)) return t('update.assets.desc.tarball');
if (/\.(7z|rar)$/.test(lower)) return t('update.assets.desc.archive');
return '';
}
/** Render the explicit "Downloads" rack at the top of the overlay body
* using the `assets` list from the release JSON. Each asset becomes a
* rack-style chip with icon + filename + description + size + ext badge.
* Checksum / signature side-files are filtered out. The rack hides when
* there are no installable assets. DOM nodes are built imperatively so
* attacker-controlled `name`/`url` strings can never inject HTML. */
function _renderReleaseAssets(): void {
const wrap = document.getElementById('release-notes-assets');
if (!wrap) return;
const assets = (_lastStatus?.release?.assets ?? []).filter(
(a) => _safeDownloadHref(a.download_url) !== null
&& !_isChecksumAsset(a.name),
);
if (assets.length === 0) {
wrap.hidden = true;
wrap.replaceChildren();
return;
}
wrap.hidden = false;
// Header row: dot + title + count pill
const head = document.createElement('div');
head.className = 'rn-assets__head';
const dot = _span('rn-assets__dot');
dot.setAttribute('aria-hidden', 'true');
head.append(
dot,
_span('rn-assets__title', t('update.assets.title')),
_span('rn-assets__count', String(assets.length)),
);
// Asset list
const list = document.createElement('div');
list.className = 'rn-assets__list';
for (const a of assets) {
const href = _safeDownloadHref(a.download_url);
if (!href) continue;
const row = document.createElement('a');
row.className = 'rn-asset';
row.href = href;
row.setAttribute('download', '');
row.target = '_blank';
row.rel = 'noopener noreferrer';
row.title = a.name;
// Icon — ICON_DOWNLOAD is a known-safe SVG string from our own bundle.
const icon = _span('rn-asset__icon');
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = ICON_DOWNLOAD;
// Filename + description stacked in a single column so they
// share the 1fr grid slot and the description doesn't push the
// size / extension chips around.
const col = document.createElement('div');
col.className = 'rn-asset__col';
col.append(_span('rn-asset__name', a.name));
const desc = _describeAsset(a.name);
if (desc) col.append(_span('rn-asset__desc', desc));
const ext = _span('rn-asset__ext', _assetExt(a.name));
row.append(icon, col);
const sizeText = _formatBytes(a.size);
if (sizeText) row.append(_span('rn-asset__size', sizeText));
row.append(ext);
list.append(row);
}
wrap.replaceChildren(head, list);
}
export function openReleaseNotes(): void { export function openReleaseNotes(): void {
const overlay = document.getElementById('release-notes-overlay'); const overlay = document.getElementById('release-notes-overlay');
const content = document.getElementById('release-notes-content'); const content = document.getElementById('release-notes-content');
if (overlay && content) { if (!overlay || !content) return;
import('marked').then(({ marked }) => {
content.innerHTML = marked.parse(_releaseNotesBody) as string; _renderReleaseNotesHeader();
overlay.style.display = 'flex';
}); import('marked').then(({ marked }) => {
} content.innerHTML = marked.parse(_releaseNotesBody) as string;
_linkInlineAssetCode(content);
_decorateReleaseNotesLinks(content);
overlay.style.display = 'flex';
// Restart the staggered reveal animation each time we open.
// Forcing a reflow re-triggers `animation` on the freshly-parsed children.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
void content.offsetWidth;
});
} }
export function closeReleaseNotes(): void { export function closeReleaseNotes(): void {
@@ -27,6 +27,7 @@ import {
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD, ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
} from '../core/icons.ts'; } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { openAuthedWs } from '../core/ws-auth.ts'; import { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
@@ -1228,46 +1229,63 @@ function _renderVsColorSwatch() {
// ── Card rendering (used by streams.js) ─────────────────────── // ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src: ValueSource) { const VALUE_BADGE: Record<string, string> = {
const icon = getValueSourceIcon(src.source_type); static: 'VALUE · K',
animated: 'VALUE · LFO',
audio: 'VALUE · AUDIO',
adaptive_time: 'VALUE · TIME',
daylight: 'VALUE · SUN',
adaptive_scene: 'VALUE · SCENE',
static_color: 'VALUE · RGB',
animated_color: 'VALUE · RGB·LFO',
adaptive_time_color: 'VALUE · RGB·TIME',
ha_entity: 'VALUE · HA',
gradient_map: 'VALUE · MAP',
css_extract: 'VALUE · STRIP',
system_metrics: 'VALUE · SYS',
};
function _valueSourceChipsAndExtras(src: ValueSource): { chips: ModChipOpts[]; metaText: string; extra: string } {
const chips: ModChipOpts[] = [];
let metaText: string = src.source_type;
let extra = '';
let propsHtml = '';
if (src.source_type === 'static') { if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`; chips.push({ icon: ICON_LED_PREVIEW, text: `${src.value ?? 1.0}`, title: t('value_source.type.static') });
metaText = `${t('value_source.type.static')} · ${src.value ?? 1.0}`;
} else if (src.source_type === 'animated') { } else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine'; const waveLabel = src.waveform || 'sine';
propsHtml = ` chips.push({ icon: ICON_ACTIVITY, text: waveLabel });
<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span> chips.push({ icon: ICON_TIMER, text: `${src.speed ?? 10} cpm` });
<span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span> chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span> metaText = `${waveLabel} · ${src.speed ?? 10} cpm`;
`;
} else if (src.source_type === 'audio') { } else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id); const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-'); const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
const audioSection = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture'; const audioSection = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
const audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture'; const audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
const modeLabel = src.mode || 'rms'; const modeLabel = src.mode || 'rms';
const audioBadge = audioSrc chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>` icon: ICON_MUSIC, text: audioName, title: t('value_source.audio_source'),
: `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`; onclick: audioSrc ? `event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')` : undefined,
propsHtml = ` });
${audioBadge} chips.push({ icon: ICON_TRENDING_UP, text: modeLabel.toUpperCase() });
<span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span> chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span> metaText = `${audioName} · ${modeLabel.toUpperCase()}`;
`;
} else if (src.source_type === 'adaptive_time') { } else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length; const pts = (src.schedule || []).length;
propsHtml = ` chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span> chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span> metaText = `${pts} schedule pts`;
`;
} else if (src.source_type === 'daylight') { } else if (src.source_type === 'daylight') {
if (src.use_real_time) { if (src.use_real_time) {
propsHtml = `<span class="stream-card-prop">${ICON_CLOCK} ${t('value_source.daylight.real_time')}</span>`; chips.push({ icon: ICON_CLOCK, text: t('value_source.daylight.real_time') || 'Real-time' });
metaText = t('value_source.daylight.real_time') || 'Real-time';
} else { } else {
propsHtml = `<span class="stream-card-prop">${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x</span>`; chips.push({ icon: ICON_TIMER, text: `${t('value_source.daylight.speed_label') || 'speed'} ${src.speed ?? 1.0}x` });
metaText = `${src.speed ?? 1.0}x`;
} }
propsHtml += `<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}</span>`; chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}\u2013${src.max_value ?? 1}` });
} else if (src.source_type === 'adaptive_scene') { } else if (src.source_type === 'adaptive_scene') {
const ps = _cachedStreams.find(s => s.id === src.picture_source_id); const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
const psName = ps ? ps.name : (src.picture_source_id || '-'); const psName = ps ? ps.name : (src.picture_source_id || '-');
@@ -1276,39 +1294,41 @@ export function createValueSourceCard(src: ValueSource) {
if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; } if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; } else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
} }
const psBadge = ps chips.push({
? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">${ICON_MONITOR} ${escapeHtml(psName)}</span>` icon: ICON_MONITOR, text: psName, title: t('value_source.picture_source'),
: `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`; onclick: ps ? `event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')` : undefined,
propsHtml = ` });
${psBadge} chips.push({ icon: ICON_REFRESH, text: src.scene_behavior || 'complement' });
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span> metaText = `${psName} · ${src.scene_behavior || 'complement'}`;
`;
} else if (src.source_type === 'static_color') { } else if (src.source_type === 'static_color') {
const rgb = (src as any).color || [255, 255, 255]; const rgb = (src as any).color || [255, 255, 255];
const hex = rgbArrayToHex(rgb); const hex = rgbArrayToHex(rgb);
propsHtml = `<span class="stream-card-prop"><span style="display:inline-block;width:12px;height:12px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span> ${hex}</span>`; chips.push({
icon: `<span style="display:inline-block;width:11px;height:11px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span>`,
text: hex,
});
metaText = hex;
} else if (src.source_type === 'animated_color') { } else if (src.source_type === 'animated_color') {
const colors = (src as any).colors || []; const colors = (src as any).colors || [];
propsHtml = ` chips.push({ icon: ICON_ACTIVITY, text: `${colors.length} ${t('value_source.animated_color.color_count') || 'colors'}` });
<span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span> chips.push({ icon: ICON_TIMER, text: `${(src as any).speed ?? 10} cpm` });
<span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span> metaText = `${colors.length} colors`;
`;
} else if (src.source_type === 'adaptive_time_color') { } else if (src.source_type === 'adaptive_time_color') {
const pts = ((src as any).schedule || []).length; const pts = ((src as any).schedule || []).length;
propsHtml = `<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>`; chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
metaText = `${pts} schedule pts`;
} else if (src.source_type === 'ha_entity') { } else if (src.source_type === 'ha_entity') {
const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id); const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-'); const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
const entityId = (src as any).entity_id || ''; const entityId = (src as any).entity_id || '';
const attr = (src as any).attribute; const attr = (src as any).attribute;
const haBadge = haSrc chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.ha_source'))}" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')">${ICON_HOME} ${escapeHtml(haName)}</span>` icon: ICON_HOME, text: haName, title: t('value_source.ha_source'),
: `<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>`; onclick: haSrc ? `event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')` : undefined,
propsHtml = ` });
${haBadge} chips.push({ icon: ICON_LINK, text: `${entityId}${attr ? '.' + attr : ''}` });
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span> chips.push({ icon: ICON_MOVE_VERTICAL, text: `${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}` });
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span> metaText = `${haName} \u00b7 ${entityId}`;
`;
} else if (src.source_type === 'gradient_map') { } else if (src.source_type === 'gradient_map') {
const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id); const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-'); const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
@@ -1319,53 +1339,70 @@ export function createValueSourceCard(src: ValueSource) {
const gradientCss = stops.length >= 2 const gradientCss = stops.length >= 2
? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})` ? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
: '#333'; : '#333';
const inputBadge = inputVs chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.gradient_map.input'))}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${(src as any).value_source_id}')">${ICON_LINK} ${escapeHtml(inputName)}</span>` icon: ICON_LINK, text: inputName, title: t('value_source.gradient_map.input'),
: `<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>`; onclick: inputVs ? `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${(src as any).value_source_id}')` : undefined,
const gradBadge = grad });
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.gradient_map.gradient'))}" onclick="event.stopPropagation(); navigateToCard('streams','gradients','gradients','data-id','${(src as any).gradient_id}')">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>` chips.push({
: `<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>`; icon: ICON_RAINBOW, text: gradName, title: t('value_source.gradient_map.gradient'),
propsHtml = ` onclick: grad ? `event.stopPropagation(); navigateToCard('streams','gradients','gradients','data-id','${(src as any).gradient_id}')` : undefined,
${inputBadge} });
${gradBadge} extra = `<div class="mod-preview mod-preview--strip" style="height:8px;background:${gradientCss};"></div>`;
<div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div> metaText = `${inputName} \u2192 ${gradName}`;
`;
} else if (src.source_type === 'css_extract') { } else if (src.source_type === 'css_extract') {
const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id); const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id);
const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-'); const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-');
const ledStart = (src as any).led_start ?? 0; const ledStart = (src as any).led_start ?? 0;
const ledEnd = (src as any).led_end ?? -1; const ledEnd = (src as any).led_end ?? -1;
const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`; const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`;
const cssBadge = cssSrc chips.push({
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.css_extract.source'))}" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${(src as any).color_strip_source_id}')">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>` icon: ICON_DROPLETS, text: cssName, title: t('value_source.css_extract.source'),
: `<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>`; onclick: cssSrc ? `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${(src as any).color_strip_source_id}')` : undefined,
propsHtml = ` });
${cssBadge} chips.push({ icon: ICON_MOVE_VERTICAL, text: `LED ${rangeLabel}` });
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span> metaText = `${cssName} \u00b7 ${rangeLabel}`;
`;
} else if (src.source_type === 'system_metrics') { } else if (src.source_type === 'system_metrics') {
const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric; const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric;
propsHtml = `<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(metricLabel)}</span>`; chips.push({ icon: ICON_ACTIVITY, text: metricLabel });
metaText = metricLabel;
} }
return wrapCard({ return { chips, metaText, extra };
type: 'template-card', }
dataAttr: 'data-id',
id: src.id, export function createValueSourceCard(src: ValueSource) {
removeOnclick: `deleteValueSource('${src.id}')`, const { chips, metaText, extra } = _valueSourceChipsAndExtras(src);
removeTitle: t('common.delete'), const badgeText = VALUE_BADGE[src.source_type] || 'VALUE \u00b7 IN';
content: `
<div class="template-card-header"> const mod: ModCardOpts = {
<div class="template-name">${icon} ${escapeHtml(src.name)}</div> head: {
</div> badge: { text: badgeText },
<div class="stream-card-props">${propsHtml}</div> name: src.name,
${renderTagChips(src.tags)} metaHtml: escapeHtml(metaText),
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`, leds: ['off'],
actions: ` menu: {
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button> duplicateOnclick: `cloneValueSource('${src.id}')`,
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button> hideOnclick: `toggleCardHidden('value-sources','${src.id}')`,
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`, deleteOnclick: `deleteValueSource('${src.id}')`,
}); },
},
body: {
desc: src.description || undefined,
chips: chips.length ? chips : undefined,
extraHtml: extra || undefined,
},
foot: {
patchState: 'idle',
patchLabel: 'VALUE',
iconActions: [
{ icon: ICON_TEST, onclick: `testValueSource('${src.id}')`, title: t('value_source.test') },
{ icon: ICON_EDIT, onclick: `editValueSource('${src.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod });
const tagsHtml = renderTagChips(src.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Helpers ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
@@ -11,6 +11,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import type { WeatherSource } from '../types.ts'; import type { WeatherSource } from '../types.ts';
@@ -267,33 +268,42 @@ export function weatherSourceGeolocate(): void {
export function createWeatherSourceCard(source: WeatherSource) { export function createWeatherSourceCard(source: WeatherSource) {
const intervalMin = Math.round(source.update_interval / 60); const intervalMin = Math.round(source.update_interval / 60);
const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider; const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider;
const coords = `${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}`;
return wrapCard({ const chips: ModChipOpts[] = [
type: 'template-card', { icon: ICON_WEATHER, text: providerLabel },
dataAttr: 'data-id', { icon: _icon(P.mapPin), text: coords, title: `${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}` },
id: source.id, { icon: _icon(P.clock), text: `${intervalMin} min` },
removeOnclick: `deleteWeatherSource('${source.id}')`, ];
removeTitle: t('common.delete'),
content: ` const mod: ModCardOpts = {
<div class="template-card-header"> head: {
<div class="template-name">${ICON_WEATHER} ${escapeHtml(source.name)}</div> badge: { text: 'WEATHER · IN' },
</div> name: source.name,
<div class="stream-card-props"> metaHtml: escapeHtml(`${providerLabel} · ${coords}`),
<span class="stream-card-prop">${ICON_WEATHER} ${providerLabel}</span> leds: ['on'],
<span class="stream-card-prop" title="${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}"> menu: {
<svg class="icon" viewBox="0 0 24 24">${P.mapPin}</svg> ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)} duplicateOnclick: `cloneWeatherSource('${source.id}')`,
</span> hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`,
<span class="stream-card-prop"> deleteOnclick: `deleteWeatherSource('${source.id}')`,
<svg class="icon" viewBox="0 0 24 24">${P.clock}</svg> ${intervalMin}min },
</span> },
</div> body: {
${renderTagChips(source.tags)} desc: source.description || undefined,
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`, chips,
actions: ` },
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('weather_source.test')}">${ICON_TEST}</button> foot: {
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button> patchState: 'live',
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`, patchLabel: 'POLLING',
}); iconActions: [
{ icon: ICON_TEST, onclick: '', title: t('weather_source.test'), dataAttrs: { 'data-action': 'test' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod });
const tagsHtml = renderTagChips(source.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
} }
// ── Event delegation ── // ── Event delegation ──
+1
View File
@@ -411,6 +411,7 @@ startTargetOverlay: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any; loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any; setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any; saveExternalUrl: (...args: any[]) => any;
revertExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any; getBaseOrigin: (...args: any[]) => any;
// ─── Appearance ─── // ─── Appearance ───
+71 -6
View File
@@ -373,6 +373,10 @@
"settings.external_url.saved": "External URL saved", "settings.external_url.saved": "External URL saved",
"settings.external_url.save_error": "Failed to save external URL", "settings.external_url.save_error": "Failed to save external URL",
"settings.general.title": "General Settings", "settings.general.title": "General Settings",
"settings.section.identity": "Identity",
"settings.section.connection": "Connection",
"settings.section.hardware": "Hardware",
"settings.section.behavior": "Behavior",
"settings.capture.title": "Capture Settings", "settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings", "settings.capture.failed": "Failed to save capture settings",
@@ -804,7 +808,11 @@
"dashboard.perf.patches.empty.idle": "Ready to launch", "dashboard.perf.patches.empty.idle": "Ready to launch",
"dashboard.perf.patches.empty.none": "No patches yet", "dashboard.perf.patches.empty.none": "No patches yet",
"dashboard.perf.total_fps": "Total FPS", "dashboard.perf.total_fps": "Total FPS",
"dashboard.perf.total_capture_fps": "Total Capture FPS", "dashboard.perf.total_capture_fps": "Total Source FPS",
"dashboard.perf.total_capture_fps_actual": "Total Capture FPS",
"dashboard.perf.network": "Network",
"dashboard.perf.device_latency": "Device Latency",
"dashboard.perf.send_timing": "Send Timing",
"dashboard.perf.errors": "Errors", "dashboard.perf.errors": "Errors",
"dashboard.perf.devices": "Devices", "dashboard.perf.devices": "Devices",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",
@@ -983,6 +991,9 @@
"scenes.capture": "Capture", "scenes.capture": "Capture",
"scenes.activate": "Activate scene", "scenes.activate": "Activate scene",
"scenes.recapture": "Recapture current state", "scenes.recapture": "Recapture current state",
"scenes.action.activate": "Activate",
"scenes.action.recapture": "Recapture",
"scenes.status.preset": "Preset",
"scenes.delete": "Delete scene", "scenes.delete": "Delete scene",
"scenes.targets_count": "targets", "scenes.targets_count": "targets",
"scenes.captured": "Scene captured", "scenes.captured": "Scene captured",
@@ -1001,9 +1012,6 @@
"scenes.error.delete_failed": "Failed to delete scene", "scenes.error.delete_failed": "Failed to delete scene",
"scenes.cloned": "Scene cloned", "scenes.cloned": "Scene cloned",
"scenes.error.clone_failed": "Failed to clone scene", "scenes.error.clone_failed": "Failed to clone scene",
"time.hours_minutes": "{h}h {m}m",
"time.minutes_seconds": "{m}m {s}s",
"time.seconds": "{s}s",
"dashboard.type.led": "LED", "dashboard.type.led": "LED",
"dashboard.type.kc": "Key Colors", "dashboard.type.kc": "Key Colors",
"aria.close": "Close", "aria.close": "Close",
@@ -1771,12 +1779,12 @@
"search.action.disable": "Disable", "search.action.disable": "Disable",
"settings.backup.label": "Backup Configuration", "settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
"settings.backup.button": "Download Backup", "settings.backup.button": "Download",
"settings.backup.success": "Backup downloaded successfully", "settings.backup.success": "Backup downloaded successfully",
"settings.backup.error": "Backup download failed", "settings.backup.error": "Backup download failed",
"settings.restore.label": "Restore Configuration", "settings.restore.label": "Restore Configuration",
"settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.", "settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.",
"settings.restore.button": "Restore from Backup", "settings.restore.button": "Restore",
"settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?", "settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?",
"settings.restore.success": "Configuration restored", "settings.restore.success": "Configuration restored",
"settings.restore.error": "Restore failed", "settings.restore.error": "Restore failed",
@@ -1855,6 +1863,15 @@
"settings.logs.filter.info_desc": "Info, warning, and errors", "settings.logs.filter.info_desc": "Info, warning, and errors",
"settings.logs.filter.warning_desc": "Warnings and errors only", "settings.logs.filter.warning_desc": "Warnings and errors only",
"settings.logs.filter.error_desc": "Errors only", "settings.logs.filter.error_desc": "Errors only",
"settings.logs.stat.lines": "LINES",
"settings.logs.stat.warn": "WARN",
"settings.logs.stat.err": "ERR",
"settings.logs.patch.idle": "STANDBY",
"settings.logs.patch.connecting": "CONNECTING",
"settings.logs.patch.live": "STREAMING",
"settings.logs.patch.error": "OFFLINE",
"settings.logs.empty.title": "Awaiting log frames",
"settings.logs.empty.sub": "Connect the WebSocket stream to begin tailing.",
"device.error.power_off_failed": "Failed to turn off device", "device.error.power_off_failed": "Failed to turn off device",
"device.error.remove_failed": "Failed to remove device", "device.error.remove_failed": "Failed to remove device",
"device.error.settings_load_failed": "Failed to load device settings", "device.error.settings_load_failed": "Failed to load device settings",
@@ -2256,6 +2273,7 @@
"settings.notifications.permission.state.granted": "Granted — OS toasts will appear", "settings.notifications.permission.state.granted": "Granted — OS toasts will appear",
"settings.notifications.permission.state.denied": "Denied — change in browser settings", "settings.notifications.permission.state.denied": "Denied — change in browser settings",
"settings.notifications.permission.state.default": "Not yet requested", "settings.notifications.permission.state.default": "Not yet requested",
"settings.notifications.permission.hint": "The browser controls OS notification permission per site. Once denied, LedGrab can no longer ask again — you need to clear it in the browser. Click the site icon (lock) in the address bar → Site settings → Notifications → Allow, then reload.",
"settings.notifications.test_button": "Send a test notification", "settings.notifications.test_button": "Send a test notification",
"settings.notifications.saved": "Notification preferences saved", "settings.notifications.saved": "Notification preferences saved",
"settings.notifications.save_error": "Failed to save notification preferences", "settings.notifications.save_error": "Failed to save notification preferences",
@@ -2267,6 +2285,33 @@
"settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)", "settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)",
"settings.notifications.channel.both.label": "Both", "settings.notifications.channel.both.label": "Both",
"settings.notifications.channel.both.desc": "In-app toast and system notification", "settings.notifications.channel.both.desc": "In-app toast and system notification",
"settings.notifications.permission.pill.granted": "GRANTED",
"settings.notifications.permission.pill.denied": "BLOCKED",
"settings.rail.group.workspace": "Workspace",
"settings.rail.group.system": "System",
"settings.save_bar.unsaved": "Unsaved changes in",
"settings.save_bar.revert": "Revert",
"settings.save_bar.save": "Save",
"settings.section.api_keys": "Identity & API",
"settings.section.server": "Server",
"settings.section.lifecycle": "Lifecycle",
"settings.section.destructive": "DESTRUCTIVE",
"settings.section.manual": "Manual",
"settings.section.notif_channels": "Channels",
"settings.section.notif_discovery": "Discovery",
"settings.section.notif_permission": "OS Permission",
"settings.api_keys.read_only": "Read-only",
"settings.api_keys.meta.one": "key",
"settings.api_keys.meta.many": "keys",
"settings.logs.sub": "Live tail of server log output, filterable by level. Opens in a full-screen overlay.",
"settings.restart.sub": "Bounce the LedGrab process. Active capture and connected devices will pause for ~3 seconds.",
"settings.restart.button": "Restart",
"settings.notif_matrix.col.event": "Event",
"settings.notif_matrix.event_count": "4 EVENTS",
"settings.auto_backup.pill.running": "RUNNING",
"update.pill.available": "UPDATE AVAILABLE",
"update.pill.error": "ERROR",
"update.pill.updated": "UP TO DATE",
"notifications.unknown_device": "Unknown device", "notifications.unknown_device": "Unknown device",
"notifications.device_online.title": "Device online", "notifications.device_online.title": "Device online",
"notifications.device_online.body": "{device} is back online", "notifications.device_online.body": "{device} is back online",
@@ -2294,6 +2339,26 @@
"update.never": "never", "update.never": "never",
"update.release_notes": "Release Notes", "update.release_notes": "Release Notes",
"update.view_release_notes": "View Release Notes", "update.view_release_notes": "View Release Notes",
"update.release_notes_hint": "What's new in the available version — read the changelog before applying.",
"update.release_notes_open": "Open",
"update.assets.title": "Downloads",
"update.assets.desc.windows_installer": "Windows installer — Start Menu shortcut, optional autostart, uninstaller",
"update.assets.desc.windows_portable": "Windows portable — unzip anywhere, run LedGrab.bat",
"update.assets.desc.windows_msi": "Windows MSI installer",
"update.assets.desc.windows_exe": "Windows executable",
"update.assets.desc.linux_tarball": "Linux archive — extract, run ./run.sh",
"update.assets.desc.linux_appimage": "Linux portable — single executable",
"update.assets.desc.linux_deb": "Debian / Ubuntu package",
"update.assets.desc.linux_rpm": "Fedora / RHEL package",
"update.assets.desc.linux_sandbox": "Sandboxed Linux package",
"update.assets.desc.macos_dmg": "macOS disk image — drag to Applications",
"update.assets.desc.macos_installer": "macOS installer package",
"update.assets.desc.android": "Android — sideload on Android 7.0+",
"update.assets.desc.android_bundle": "Android App Bundle (Play Store)",
"update.assets.desc.ios": "iOS application",
"update.assets.desc.zip_archive": "ZIP archive",
"update.assets.desc.tarball": "Tar archive",
"update.assets.desc.archive": "Compressed archive",
"update.auto_check_label": "Auto-Check Settings", "update.auto_check_label": "Auto-Check Settings",
"update.auto_check_hint": "Periodically check for new releases in the background.", "update.auto_check_hint": "Periodically check for new releases in the background.",
"update.enable": "Enable auto-check", "update.enable": "Enable auto-check",
+71 -6
View File
@@ -377,6 +377,10 @@
"settings.external_url.saved": "Внешний URL сохранён", "settings.external_url.saved": "Внешний URL сохранён",
"settings.external_url.save_error": "Не удалось сохранить внешний URL", "settings.external_url.save_error": "Не удалось сохранить внешний URL",
"settings.general.title": "Основные Настройки", "settings.general.title": "Основные Настройки",
"settings.section.identity": "Идентификация",
"settings.section.connection": "Подключение",
"settings.section.hardware": "Оборудование",
"settings.section.behavior": "Поведение",
"settings.capture.title": "Настройки Захвата", "settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата", "settings.capture.failed": "Не удалось сохранить настройки захвата",
@@ -785,7 +789,11 @@
"dashboard.perf.patches.empty.idle": "Готов к запуску", "dashboard.perf.patches.empty.idle": "Готов к запуску",
"dashboard.perf.patches.empty.none": "Каналов пока нет", "dashboard.perf.patches.empty.none": "Каналов пока нет",
"dashboard.perf.total_fps": "Общий FPS", "dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.total_capture_fps": "Общий FPS захвата", "dashboard.perf.total_capture_fps": "Общий FPS источников",
"dashboard.perf.total_capture_fps_actual": "Общий FPS захвата",
"dashboard.perf.network": "Сеть",
"dashboard.perf.device_latency": "Задержка устройств",
"dashboard.perf.send_timing": "Время отправки",
"dashboard.perf.errors": "Ошибки", "dashboard.perf.errors": "Ошибки",
"dashboard.perf.devices": "Устройства", "dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП", "dashboard.perf.cpu": "ЦП",
@@ -964,6 +972,9 @@
"scenes.capture": "Захват", "scenes.capture": "Захват",
"scenes.activate": "Активировать сцену", "scenes.activate": "Активировать сцену",
"scenes.recapture": "Перезахватить текущее состояние", "scenes.recapture": "Перезахватить текущее состояние",
"scenes.action.activate": "Активировать",
"scenes.action.recapture": "Перезахват",
"scenes.status.preset": "Пресет",
"scenes.delete": "Удалить сцену", "scenes.delete": "Удалить сцену",
"scenes.targets_count": "целей", "scenes.targets_count": "целей",
"scenes.captured": "Сцена захвачена", "scenes.captured": "Сцена захвачена",
@@ -982,9 +993,6 @@
"scenes.error.delete_failed": "Не удалось удалить сцену", "scenes.error.delete_failed": "Не удалось удалить сцену",
"scenes.cloned": "Сцена клонирована", "scenes.cloned": "Сцена клонирована",
"scenes.error.clone_failed": "Не удалось клонировать сцену", "scenes.error.clone_failed": "Не удалось клонировать сцену",
"time.hours_minutes": "{h}ч {m}м",
"time.minutes_seconds": "{m}м {s}с",
"time.seconds": "{s}с",
"dashboard.type.led": "LED", "dashboard.type.led": "LED",
"dashboard.type.kc": "Цвета клавиш", "dashboard.type.kc": "Цвета клавиш",
"aria.close": "Закрыть", "aria.close": "Закрыть",
@@ -1587,12 +1595,12 @@
"search.action.disable": "Отключить", "search.action.disable": "Отключить",
"settings.backup.label": "Резервное копирование", "settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию", "settings.backup.button": "Скачать",
"settings.backup.success": "Резервная копия скачана", "settings.backup.success": "Резервная копия скачана",
"settings.backup.error": "Ошибка скачивания резервной копии", "settings.backup.error": "Ошибка скачивания резервной копии",
"settings.restore.label": "Восстановление конфигурации", "settings.restore.label": "Восстановление конфигурации",
"settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.", "settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.",
"settings.restore.button": "Восстановить из копии", "settings.restore.button": "Восстановить",
"settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?", "settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?",
"settings.restore.success": "Конфигурация восстановлена", "settings.restore.success": "Конфигурация восстановлена",
"settings.restore.error": "Ошибка восстановления", "settings.restore.error": "Ошибка восстановления",
@@ -1671,6 +1679,15 @@
"settings.logs.filter.info_desc": "Info, предупреждения и ошибки", "settings.logs.filter.info_desc": "Info, предупреждения и ошибки",
"settings.logs.filter.warning_desc": "Только предупреждения и ошибки", "settings.logs.filter.warning_desc": "Только предупреждения и ошибки",
"settings.logs.filter.error_desc": "Только ошибки", "settings.logs.filter.error_desc": "Только ошибки",
"settings.logs.stat.lines": "СТРОК",
"settings.logs.stat.warn": "ВНИМ",
"settings.logs.stat.err": "ОШИБ",
"settings.logs.patch.idle": "ОЖИДАНИЕ",
"settings.logs.patch.connecting": "ПОДКЛЮЧЕНИЕ",
"settings.logs.patch.live": "ПОТОК",
"settings.logs.patch.error": "ОФЛАЙН",
"settings.logs.empty.title": "Ожидание журналов",
"settings.logs.empty.sub": "Подключите WebSocket-поток, чтобы начать трансляцию.",
"device.error.power_off_failed": "Не удалось выключить устройство", "device.error.power_off_failed": "Не удалось выключить устройство",
"device.error.remove_failed": "Не удалось удалить устройство", "device.error.remove_failed": "Не удалось удалить устройство",
"device.error.settings_load_failed": "Не удалось загрузить настройки устройства", "device.error.settings_load_failed": "Не удалось загрузить настройки устройства",
@@ -1972,6 +1989,7 @@
"settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС", "settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС",
"settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера", "settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера",
"settings.notifications.permission.state.default": "Разрешение ещё не запрошено", "settings.notifications.permission.state.default": "Разрешение ещё не запрошено",
"settings.notifications.permission.hint": "Браузер управляет разрешением на системные уведомления для каждого сайта отдельно. После отказа LedGrab уже не может запросить разрешение снова — его нужно сбросить в браузере. Нажмите на значок сайта (замок) в адресной строке → Настройки сайта → Уведомления → Разрешить, затем перезагрузите страницу.",
"settings.notifications.test_button": "Отправить тестовое уведомление", "settings.notifications.test_button": "Отправить тестовое уведомление",
"settings.notifications.saved": "Настройки уведомлений сохранены", "settings.notifications.saved": "Настройки уведомлений сохранены",
"settings.notifications.save_error": "Не удалось сохранить настройки уведомлений", "settings.notifications.save_error": "Не удалось сохранить настройки уведомлений",
@@ -1983,6 +2001,33 @@
"settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)", "settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)",
"settings.notifications.channel.both.label": "Оба", "settings.notifications.channel.both.label": "Оба",
"settings.notifications.channel.both.desc": "Снэк и системное уведомление", "settings.notifications.channel.both.desc": "Снэк и системное уведомление",
"settings.notifications.permission.pill.granted": "РАЗРЕШЕНО",
"settings.notifications.permission.pill.denied": "ЗАБЛОКИРОВАНО",
"settings.rail.group.workspace": "Рабочая зона",
"settings.rail.group.system": "Система",
"settings.save_bar.unsaved": "Несохранённые изменения в поле",
"settings.save_bar.revert": "Отменить",
"settings.save_bar.save": "Сохранить",
"settings.section.api_keys": "Идентификация и API",
"settings.section.server": "Сервер",
"settings.section.lifecycle": "Жизненный цикл",
"settings.section.destructive": "ОПАСНО",
"settings.section.manual": "Вручную",
"settings.section.notif_channels": "Каналы",
"settings.section.notif_discovery": "Обнаружение",
"settings.section.notif_permission": "Разрешение ОС",
"settings.api_keys.read_only": "Только чтение",
"settings.api_keys.meta.one": "ключ",
"settings.api_keys.meta.many": "ключей",
"settings.logs.sub": "Живой поток лога сервера с фильтром по уровню. Открывается в полноэкранном слое.",
"settings.restart.sub": "Перезапустить процесс LedGrab. Захват и подключенные устройства приостановятся примерно на 3 секунды.",
"settings.restart.button": "Перезапустить",
"settings.notif_matrix.col.event": "Событие",
"settings.notif_matrix.event_count": "4 СОБЫТИЯ",
"settings.auto_backup.pill.running": "АКТИВНО",
"update.pill.available": "ДОСТУПНО ОБНОВЛЕНИЕ",
"update.pill.error": "ОШИБКА",
"update.pill.updated": "АКТУАЛЬНО",
"notifications.unknown_device": "Неизвестное устройство", "notifications.unknown_device": "Неизвестное устройство",
"notifications.device_online.title": "Устройство в сети", "notifications.device_online.title": "Устройство в сети",
"notifications.device_online.body": "{device} снова в сети", "notifications.device_online.body": "{device} снова в сети",
@@ -2010,6 +2055,26 @@
"update.never": "никогда", "update.never": "никогда",
"update.release_notes": "Примечания к релизу", "update.release_notes": "Примечания к релизу",
"update.view_release_notes": "Открыть примечания к релизу", "update.view_release_notes": "Открыть примечания к релизу",
"update.release_notes_hint": "Что нового в доступной версии — прочитайте список изменений перед применением.",
"update.release_notes_open": "Открыть",
"update.assets.title": "Загрузки",
"update.assets.desc.windows_installer": "Установщик Windows — ярлык в меню «Пуск», опциональный автозапуск, деинсталлятор",
"update.assets.desc.windows_portable": "Windows portable — распакуйте в любую папку и запустите LedGrab.bat",
"update.assets.desc.windows_msi": "MSI-установщик Windows",
"update.assets.desc.windows_exe": "Исполняемый файл Windows",
"update.assets.desc.linux_tarball": "Архив Linux — распакуйте и запустите ./run.sh",
"update.assets.desc.linux_appimage": "Linux portable — один исполняемый файл",
"update.assets.desc.linux_deb": "Пакет Debian / Ubuntu",
"update.assets.desc.linux_rpm": "Пакет Fedora / RHEL",
"update.assets.desc.linux_sandbox": "Sandbox-пакет Linux",
"update.assets.desc.macos_dmg": "Образ диска macOS — перетащите в «Программы»",
"update.assets.desc.macos_installer": "Установщик macOS",
"update.assets.desc.android": "Android — sideload на Android 7.0+",
"update.assets.desc.android_bundle": "Android App Bundle (Play Store)",
"update.assets.desc.ios": "Приложение iOS",
"update.assets.desc.zip_archive": "Архив ZIP",
"update.assets.desc.tarball": "Tar-архив",
"update.assets.desc.archive": "Сжатый архив",
"update.auto_check_label": "Автоматическая проверка", "update.auto_check_label": "Автоматическая проверка",
"update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.", "update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.",
"update.enable": "Включить автопроверку", "update.enable": "Включить автопроверку",
+71 -6
View File
@@ -377,6 +377,10 @@
"settings.external_url.saved": "外部 URL 已保存", "settings.external_url.saved": "外部 URL 已保存",
"settings.external_url.save_error": "保存外部 URL 失败", "settings.external_url.save_error": "保存外部 URL 失败",
"settings.general.title": "常规设置", "settings.general.title": "常规设置",
"settings.section.identity": "标识",
"settings.section.connection": "连接",
"settings.section.hardware": "硬件",
"settings.section.behavior": "行为",
"settings.capture.title": "采集设置", "settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新", "settings.capture.saved": "采集设置已更新",
"settings.capture.failed": "保存采集设置失败", "settings.capture.failed": "保存采集设置失败",
@@ -785,7 +789,11 @@
"dashboard.perf.patches.empty.idle": "准备就绪", "dashboard.perf.patches.empty.idle": "准备就绪",
"dashboard.perf.patches.empty.none": "暂无通道", "dashboard.perf.patches.empty.none": "暂无通道",
"dashboard.perf.total_fps": "总帧率", "dashboard.perf.total_fps": "总帧率",
"dashboard.perf.total_capture_fps": "总采集帧率", "dashboard.perf.total_capture_fps": "总输入帧率",
"dashboard.perf.total_capture_fps_actual": "总采集帧率",
"dashboard.perf.network": "网络",
"dashboard.perf.device_latency": "设备延迟",
"dashboard.perf.send_timing": "发送耗时",
"dashboard.perf.errors": "错误", "dashboard.perf.errors": "错误",
"dashboard.perf.devices": "设备", "dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",
@@ -964,6 +972,9 @@
"scenes.capture": "捕获", "scenes.capture": "捕获",
"scenes.activate": "激活场景", "scenes.activate": "激活场景",
"scenes.recapture": "重新捕获当前状态", "scenes.recapture": "重新捕获当前状态",
"scenes.action.activate": "激活",
"scenes.action.recapture": "重新捕获",
"scenes.status.preset": "预设",
"scenes.delete": "删除场景", "scenes.delete": "删除场景",
"scenes.targets_count": "目标", "scenes.targets_count": "目标",
"scenes.captured": "场景已捕获", "scenes.captured": "场景已捕获",
@@ -982,9 +993,6 @@
"scenes.error.delete_failed": "删除场景失败", "scenes.error.delete_failed": "删除场景失败",
"scenes.cloned": "场景已克隆", "scenes.cloned": "场景已克隆",
"scenes.error.clone_failed": "克隆场景失败", "scenes.error.clone_failed": "克隆场景失败",
"time.hours_minutes": "{h}时 {m}分",
"time.minutes_seconds": "{m}分 {s}秒",
"time.seconds": "{s}秒",
"dashboard.type.led": "LED", "dashboard.type.led": "LED",
"dashboard.type.kc": "关键颜色", "dashboard.type.kc": "关键颜色",
"aria.close": "关闭", "aria.close": "关闭",
@@ -1587,12 +1595,12 @@
"search.action.disable": "禁用", "search.action.disable": "禁用",
"settings.backup.label": "备份配置", "settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份", "settings.backup.button": "下载",
"settings.backup.success": "备份下载成功", "settings.backup.success": "备份下载成功",
"settings.backup.error": "备份下载失败", "settings.backup.error": "备份下载失败",
"settings.restore.label": "恢复配置", "settings.restore.label": "恢复配置",
"settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。", "settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。",
"settings.restore.button": "从备份恢复", "settings.restore.button": "恢复",
"settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?", "settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?",
"settings.restore.success": "配置已恢复", "settings.restore.success": "配置已恢复",
"settings.restore.error": "恢复失败", "settings.restore.error": "恢复失败",
@@ -1671,6 +1679,15 @@
"settings.logs.filter.info_desc": "Info、警告和错误", "settings.logs.filter.info_desc": "Info、警告和错误",
"settings.logs.filter.warning_desc": "仅警告和错误", "settings.logs.filter.warning_desc": "仅警告和错误",
"settings.logs.filter.error_desc": "仅错误", "settings.logs.filter.error_desc": "仅错误",
"settings.logs.stat.lines": "行数",
"settings.logs.stat.warn": "警告",
"settings.logs.stat.err": "错误",
"settings.logs.patch.idle": "待机",
"settings.logs.patch.connecting": "连接中",
"settings.logs.patch.live": "传输中",
"settings.logs.patch.error": "离线",
"settings.logs.empty.title": "等待日志",
"settings.logs.empty.sub": "连接 WebSocket 流以开始实时跟踪。",
"device.error.power_off_failed": "关闭设备失败", "device.error.power_off_failed": "关闭设备失败",
"device.error.remove_failed": "移除设备失败", "device.error.remove_failed": "移除设备失败",
"device.error.settings_load_failed": "加载设备设置失败", "device.error.settings_load_failed": "加载设备设置失败",
@@ -1970,6 +1987,7 @@
"settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示", "settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示",
"settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改", "settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改",
"settings.notifications.permission.state.default": "尚未请求授权", "settings.notifications.permission.state.default": "尚未请求授权",
"settings.notifications.permission.hint": "浏览器为每个站点单独管理系统通知权限。一旦被拒绝,LedGrab 将无法再次请求 — 必须在浏览器中重置。点击地址栏中的站点图标(锁形)→ 站点设置 → 通知 → 允许,然后刷新页面。",
"settings.notifications.test_button": "发送测试通知", "settings.notifications.test_button": "发送测试通知",
"settings.notifications.saved": "通知偏好已保存", "settings.notifications.saved": "通知偏好已保存",
"settings.notifications.save_error": "保存通知偏好失败", "settings.notifications.save_error": "保存通知偏好失败",
@@ -1981,6 +1999,33 @@
"settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)", "settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)",
"settings.notifications.channel.both.label": "两者", "settings.notifications.channel.both.label": "两者",
"settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示", "settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示",
"settings.notifications.permission.pill.granted": "已授权",
"settings.notifications.permission.pill.denied": "已阻止",
"settings.rail.group.workspace": "工作区",
"settings.rail.group.system": "系统",
"settings.save_bar.unsaved": "字段未保存的更改:",
"settings.save_bar.revert": "还原",
"settings.save_bar.save": "保存",
"settings.section.api_keys": "身份与 API",
"settings.section.server": "服务器",
"settings.section.lifecycle": "生命周期",
"settings.section.destructive": "危险操作",
"settings.section.manual": "手动",
"settings.section.notif_channels": "通道",
"settings.section.notif_discovery": "发现",
"settings.section.notif_permission": "系统权限",
"settings.api_keys.read_only": "只读",
"settings.api_keys.meta.one": "个密钥",
"settings.api_keys.meta.many": "个密钥",
"settings.logs.sub": "服务器日志的实时流,可按级别过滤。在全屏覆盖层中打开。",
"settings.restart.sub": "重启 LedGrab 进程。捕获和已连接设备将暂停约 3 秒。",
"settings.restart.button": "重启",
"settings.notif_matrix.col.event": "事件",
"settings.notif_matrix.event_count": "4 个事件",
"settings.auto_backup.pill.running": "运行中",
"update.pill.available": "可用更新",
"update.pill.error": "错误",
"update.pill.updated": "已是最新",
"notifications.unknown_device": "未知设备", "notifications.unknown_device": "未知设备",
"notifications.device_online.title": "设备已上线", "notifications.device_online.title": "设备已上线",
"notifications.device_online.body": "{device} 已重新上线", "notifications.device_online.body": "{device} 已重新上线",
@@ -2008,6 +2053,26 @@
"update.never": "从未", "update.never": "从未",
"update.release_notes": "发布说明", "update.release_notes": "发布说明",
"update.view_release_notes": "查看发布说明", "update.view_release_notes": "查看发布说明",
"update.release_notes_hint": "当前可用版本的更新内容 — 应用前请先查看变更日志。",
"update.release_notes_open": "打开",
"update.assets.title": "下载",
"update.assets.desc.windows_installer": "Windows 安装程序 — 开始菜单快捷方式、可选自启动、卸载器",
"update.assets.desc.windows_portable": "Windows 便携版 — 解压后运行 LedGrab.bat",
"update.assets.desc.windows_msi": "Windows MSI 安装程序",
"update.assets.desc.windows_exe": "Windows 可执行文件",
"update.assets.desc.linux_tarball": "Linux 归档 — 解压后运行 ./run.sh",
"update.assets.desc.linux_appimage": "Linux 便携版 — 单文件可执行",
"update.assets.desc.linux_deb": "Debian / Ubuntu 软件包",
"update.assets.desc.linux_rpm": "Fedora / RHEL 软件包",
"update.assets.desc.linux_sandbox": "Linux 沙盒软件包",
"update.assets.desc.macos_dmg": "macOS 磁盘镜像 — 拖入「应用程序」",
"update.assets.desc.macos_installer": "macOS 安装包",
"update.assets.desc.android": "Android — 在 Android 7.0+ 上侧载",
"update.assets.desc.android_bundle": "Android App BundlePlay Store",
"update.assets.desc.ios": "iOS 应用",
"update.assets.desc.zip_archive": "ZIP 归档",
"update.assets.desc.tarball": "Tar 归档",
"update.assets.desc.archive": "压缩归档",
"update.auto_check_label": "自动检查设置", "update.auto_check_label": "自动检查设置",
"update.auto_check_hint": "在后台定期检查新版本。", "update.auto_check_hint": "在后台定期检查新版本。",
"update.enable": "启用自动检查", "update.enable": "启用自动检查",
@@ -1,4 +1,7 @@
<!-- General Settings Modal --> <!-- General Settings Modal — sectioned "rack panel" layout. Form-groups keep
their original IDs so devices.ts can still toggle individual field
visibility; _updateSettingsSectionVisibility() hides whole sections
once all their form-groups end up display:none for the current device. -->
<div id="device-settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-modal-title"> <div id="device-settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,219 +12,264 @@
<form id="device-settings-form"> <form id="device-settings-form">
<input type="hidden" id="settings-device-id"> <input type="hidden" id="settings-device-id">
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<label for="settings-device-name" data-i18n="device.name">Device Name:</label> <section class="ds-section" data-ds-key="identity">
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required> <div class="ds-section-header">
<div id="device-tags-container"></div> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="settings-device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
<div id="device-tags-container"></div>
</div>
</div>
</section>
<div class="form-group" id="settings-url-group"> <!-- ── 02 · CONNECTION ─────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="connection" data-ch="cyan">
<label for="settings-device-url" data-i18n="device.url">URL:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small> <div class="ds-section-body">
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required> <div class="form-group" id="settings-url-group">
</div> <div class="label-row">
<div class="form-group" id="settings-serial-port-group" style="display: none;"> <label for="settings-device-url" data-i18n="device.url">URL:</label>
<div class="label-row"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<label for="settings-serial-port" data-i18n="device.serial_port">Serial Port:</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
</div> <input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small> </div>
<select id="settings-serial-port"></select> <div class="form-group" id="settings-serial-port-group" style="display: none;">
</div> <div class="label-row">
<label for="settings-serial-port" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="settings-serial-port"></select>
</div>
<div class="form-group" id="settings-zone-group" style="display: none;"> <div class="form-group" id="settings-ble-family-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label data-i18n="device.openrgb.zone">Zones:</label> <label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.openrgb.zone.hint">Select which LED zones to control (leave all unchecked for all zones)</small> <small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small>
<div id="settings-zone-list" class="zone-checkbox-list"></div> <select id="settings-ble-family">
</div> <option value="sp110e">SP110E / SP108E</option>
<div class="form-group" id="settings-zone-mode-group" style="display: none;"> <option value="triones">Triones / HappyLighting / LEDnet</option>
<div class="label-row"> <option value="zengge">Zengge / iLightsIn</option>
<label data-i18n="device.openrgb.mode">Zone mode:</label> <option value="govee">Govee (experimental)</option>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </select>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.openrgb.mode.hint">Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.</small> <div class="form-group" id="settings-ble-govee-key-group" style="display: none;">
<div class="zone-mode-radios"> <div class="label-row">
<label class="zone-mode-option"> <label for="settings-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
<input type="radio" name="settings-zone-mode" value="combined" checked> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<span data-i18n="device.openrgb.mode.combined">Combined strip</span> </div>
</label> <small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
<label class="zone-mode-option"> <input type="text" id="settings-ble-govee-key"
<input type="radio" name="settings-zone-mode" value="separate"> data-i18n-placeholder="device.ble.govee_key.placeholder"
<span data-i18n="device.openrgb.mode.separate">Independent zones</span> placeholder="32 hex digits, e.g. 0102…1f20">
</label> </div>
</div>
</div>
<div class="form-group" id="settings-led-count-group" style="display: none;">
<div class="label-row">
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="settings-led-count" min="1" max="10000" oninput="updateSettingsBaudFpsHint()">
</div>
<div class="form-group" id="settings-baud-rate-group" style="display: none;">
<div class="label-row">
<label for="settings-baud-rate" data-i18n="device.baud_rate">Baud Rate:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.baud_rate.hint">Serial communication speed. Higher = more FPS but requires matching Arduino sketch.</small>
<select id="settings-baud-rate" onchange="updateSettingsBaudFpsHint()">
<option value="115200">115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="500000">500000</option>
<option value="921600">921600</option>
<option value="1000000">1000000</option>
<option value="1500000">1500000</option>
<option value="2000000">2000000</option>
</select>
<small id="settings-baud-fps-hint" class="fps-hint" style="display:none"></small>
</div>
<div class="form-group" id="settings-led-type-group" style="display: none;"> <div class="form-group" id="settings-zone-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-led-type" data-i18n="device.led_type">LED Type:</label> <label data-i18n="device.openrgb.zone">Zones:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.led_type.hint">RGB (3 channels) or RGBW (4 channels with dedicated white)</small> <small class="input-hint" style="display:none" data-i18n="device.openrgb.zone.hint">Select which LED zones to control (leave all unchecked for all zones)</small>
<select id="settings-led-type"> <div id="settings-zone-list" class="zone-checkbox-list"></div>
<option value="rgb">RGB</option> </div>
<option value="rgbw">RGBW</option>
</select>
</div>
<div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
</div>
<div class="form-group" id="settings-dmx-protocol-group" style="display: none;"> <div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label> <label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_protocol.hint">Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568</small> <small class="input-hint" style="display:none" data-i18n="device.group.children.hint">Select devices to include in this group (order matters for sequence mode)</small>
<select id="settings-dmx-protocol"> <div id="settings-group-children-list" class="group-children-list"></div>
<option value="artnet">Art-Net</option> <button type="button" class="btn btn-sm btn-secondary" id="settings-group-add-child-btn" onclick="addGroupChildSettings()" data-i18n="device.group.add_child">+ Add Device</button>
<option value="sacn">sACN (E1.31)</option> </div>
</select>
</div>
<div class="form-group" id="settings-dmx-start-universe-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-start-universe" data-i18n="device.dmx_start_universe">Start Universe:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_universe.hint">First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.</small>
<input type="number" id="settings-dmx-start-universe" min="0" max="32767" value="0">
</div>
<div class="form-group" id="settings-dmx-start-channel-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-start-channel" data-i18n="device.dmx_start_channel">Start Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
</div>
<!-- BLE LED Controller fields --> <div class="form-group" id="settings-ws-url-group" style="display: none;">
<div class="form-group" id="settings-ble-family-group" style="display: none;"> <div class="label-row">
<div class="label-row"> <label for="settings-ws-url" data-i18n="device.ws_url">Connection URL:</label>
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </div>
<small class="input-hint" style="display:none" data-i18n="device.ws_url.hint">WebSocket URL for clients to connect and receive LED data</small>
<div class="ws-url-row">
<input type="text" id="settings-ws-url" readonly>
<button type="button" class="btn btn-sm btn-secondary" onclick="copyWsUrl()" title="Copy">&#x1F4CB;</button>
</div>
</div>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small> </section>
<select id="settings-ble-family">
<option value="sp110e">SP110E / SP108E</option>
<option value="triones">Triones / HappyLighting / LEDnet</option>
<option value="zengge">Zengge / iLightsIn</option>
<option value="govee">Govee (experimental)</option>
</select>
</div>
<div class="form-group" id="settings-ble-govee-key-group" style="display: none;">
<div class="label-row">
<label for="settings-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
<input type="text" id="settings-ble-govee-key"
data-i18n-placeholder="device.ble.govee_key.placeholder"
placeholder="32 hex digits, e.g. 0102…1f20">
</div>
<!-- Group device fields --> <!-- ── 03 · HARDWARE ───────────────────────────────── -->
<div class="form-group" id="settings-group-children-group" style="display: none;"> <section class="ds-section" data-ds-key="hardware" data-ch="amber">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="device.group.children">Child Devices:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-title" data-i18n="settings.section.hardware">Hardware</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.group.children.hint">Select devices to include in this group (order matters for sequence mode)</small> <div class="ds-section-body">
<div id="settings-group-children-list" class="group-children-list"></div> <div class="form-group" id="settings-led-count-group" style="display: none;">
<button type="button" class="btn btn-sm btn-secondary" id="settings-group-add-child-btn" onclick="addGroupChildSettings()" data-i18n="device.group.add_child">+ Add Device</button> <div class="label-row">
</div> <label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
<div class="form-group" id="settings-group-mode-group" style="display: none;"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="label-row"> </div>
<label data-i18n="device.group.mode">Group Mode:</label> <small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <input type="number" id="settings-led-count" min="1" max="10000" oninput="updateSettingsBaudFpsHint()">
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.group.mode.hint">Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.</small> <div class="form-group" id="settings-led-type-group" style="display: none;">
<select id="settings-group-mode-select"> <div class="label-row">
<option value="sequence">Sequence</option> <label for="settings-led-type" data-i18n="device.led_type">LED Type:</label>
<option value="independent">Independent</option> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</select> </div>
</div> <small class="input-hint" style="display:none" data-i18n="device.led_type.hint">RGB (3 channels) or RGBW (4 channels with dedicated white)</small>
<select id="settings-led-type">
<option value="rgb">RGB</option>
<option value="rgbw">RGBW</option>
</select>
</div>
<div class="form-group" id="settings-health-interval-group"> <div class="form-group" id="settings-baud-rate-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label> <label for="settings-baud-rate" data-i18n="device.baud_rate">Baud Rate:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the device status (5-600 seconds)</small> <small class="input-hint" style="display:none" data-i18n="device.baud_rate.hint">Serial communication speed. Higher = more FPS but requires matching Arduino sketch.</small>
<input type="number" id="settings-health-interval" min="5" max="600" value="30"> <select id="settings-baud-rate" onchange="updateSettingsBaudFpsHint()">
</div> <option value="115200">115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="500000">500000</option>
<option value="921600">921600</option>
<option value="1000000">1000000</option>
<option value="1500000">1500000</option>
<option value="2000000">2000000</option>
</select>
<small id="settings-baud-fps-hint" class="fps-hint" style="display:none"></small>
</div>
<div class="form-group settings-toggle-group" id="settings-auto-shutdown-group"> <div class="form-group" id="settings-zone-mode-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label data-i18n="settings.auto_shutdown">Auto Restore:</label> <label data-i18n="device.openrgb.mode">Zone mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="settings.auto_shutdown.hint">Restore device to idle state when targets stop or server shuts down</small> <small class="input-hint" style="display:none" data-i18n="device.openrgb.mode.hint">Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.</small>
<label class="settings-toggle"> <div class="zone-mode-radios">
<input type="checkbox" id="settings-auto-shutdown"> <label class="zone-mode-option">
<span class="settings-toggle-slider"></span> <input type="radio" name="settings-zone-mode" value="combined" checked>
</label> <span data-i18n="device.openrgb.mode.combined">Combined strip</span>
</div> </label>
<label class="zone-mode-option">
<input type="radio" name="settings-zone-mode" value="separate">
<span data-i18n="device.openrgb.mode.separate">Independent zones</span>
</label>
</div>
</div>
<div class="form-group" id="settings-group-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.mode">Group Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.mode.hint">Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.</small>
<select id="settings-group-mode-select">
<option value="sequence">Sequence</option>
<option value="independent">Independent</option>
</select>
</div>
<div class="form-group" id="settings-ws-url-group" style="display: none;"> <div class="form-group" id="settings-dmx-protocol-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-ws-url" data-i18n="device.ws_url">Connection URL:</label> <label for="settings-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.ws_url.hint">WebSocket URL for clients to connect and receive LED data</small> <small class="input-hint" style="display:none" data-i18n="device.dmx_protocol.hint">Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568</small>
<div class="ws-url-row"> <select id="settings-dmx-protocol">
<input type="text" id="settings-ws-url" readonly> <option value="artnet">Art-Net</option>
<button type="button" class="btn btn-sm btn-secondary" onclick="copyWsUrl()" title="Copy">&#x1F4CB;</button> <option value="sacn">sACN (E1.31)</option>
</div> </select>
</div> </div>
<div class="ds-pair-row">
<div class="form-group" id="settings-dmx-start-universe-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-start-universe" data-i18n="device.dmx_start_universe">Start Universe:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_universe.hint">First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.</small>
<input type="number" id="settings-dmx-start-universe" min="0" max="32767" value="0">
</div>
<div class="form-group" id="settings-dmx-start-channel-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-start-channel" data-i18n="device.dmx_start_channel">Start Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
</div>
</div>
<div class="form-group" id="settings-cspt-group"> <div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label> <label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
</div>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small> </section>
<select id="settings-css-processing-template">
<option value=""></option> <!-- ── 04 · BEHAVIOR ───────────────────────────────── -->
</select> <section class="ds-section" data-ds-key="behavior" data-ch="violet">
</div> <div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.behavior">Behavior</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="settings-health-interval-group">
<div class="label-row">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the device status (5-600 seconds)</small>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
</div>
<div class="form-group settings-toggle-group ds-toggle-row" id="settings-auto-shutdown-group">
<div class="ds-toggle-text">
<div class="label-row">
<label data-i18n="settings.auto_shutdown">Auto Restore:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.auto_shutdown.hint">Restore device to idle state when targets stop or server shuts down</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="settings-auto-shutdown">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="settings-cspt-group">
<div class="label-row">
<label for="settings-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
<select id="settings-css-processing-template">
<option value=""></option>
</select>
</div>
</div>
</section>
<div id="device-settings-error" class="error-message" style="display: none;"></div> <div id="device-settings-error" class="error-message" style="display: none;"></div>
</form> </form>
+630 -323
View File
@@ -1,358 +1,665 @@
<!-- Settings Modal --> <!--
Global Settings Modal — Lumenworks rack-panel layout.
Anatomy:
- Left rail (settings-rail) replaces the old icon-only top tab strip.
- Each tab body is a stack of .ds-section panels, channel-coded.
- Element IDs are preserved verbatim so settings.ts handlers continue to
work (saveExternalUrl, saveAutoBackupSettings, restartServer, etc.).
-->
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title"> <div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="modal-content" style="max-width: 520px;"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2> <h2 id="settings-modal-title">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
<span data-i18n="settings.title">Settings</span>
</h2>
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<!-- Tab bar — icon-only so labels never overflow at any locale. <div class="settings-layout">
The translated label remains available as title (hover tooltip)
and aria-label (screen readers). -->
<div class="settings-tab-bar" role="tablist">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" role="tab" data-i18n-title="settings.tab.general" data-i18n-aria-label="settings.tab.general" title="General" aria-label="General">
<svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" role="tab" data-i18n-title="settings.tab.backup" data-i18n-aria-label="settings.tab.backup" title="Backup" aria-label="Backup">
<svg class="icon" viewBox="0 0 24 24"><line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="notifications" onclick="switchSettingsTab('notifications')" role="tab" data-i18n-title="settings.tab.notifications" data-i18n-aria-label="settings.tab.notifications" title="Notifications" aria-label="Notifications">
<svg class="icon" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" role="tab" data-i18n-title="settings.tab.appearance" data-i18n-aria-label="settings.tab.appearance" title="Appearance" aria-label="Appearance">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" role="tab" data-i18n-title="settings.tab.updates" data-i18n-aria-label="settings.tab.updates" title="Updates" aria-label="Updates">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" role="tab" data-i18n-title="settings.tab.about" data-i18n-aria-label="settings.tab.about" title="About" aria-label="About">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
</div>
<div class="modal-body"> <!-- ───────────── LEFT RAIL ───────────── -->
<!-- ═══ General tab ═══ --> <nav class="settings-rail" role="tablist" aria-label="Settings sections">
<div id="settings-panel-general" class="settings-panel active"> <div class="settings-rail-group" data-i18n="settings.rail.group.workspace">Workspace</div>
<!-- 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>
<!-- External URL --> <button class="settings-rail-btn active" data-settings-tab="general" data-rail-ch="amber" onclick="switchSettingsTab('general')" role="tab" aria-label="General" data-i18n-aria-label="settings.tab.general">
<div class="form-group"> <svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
<div class="label-row"> <span class="settings-rail-label" data-i18n="settings.tab.general">General</span>
<label data-i18n="settings.external_url.label">External URL</label> <span class="settings-rail-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </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-icon btn-primary" onclick="saveExternalUrl()" title="Save" data-i18n-title="settings.external_url.save"><svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></button>
</div>
</div>
<!-- Log Level section --> <button class="settings-rail-btn" data-settings-tab="backup" data-rail-ch="cyan" onclick="switchSettingsTab('backup')" role="tab" aria-label="Backup" data-i18n-aria-label="settings.tab.backup">
<div class="form-group"> <svg class="icon" viewBox="0 0 24 24"><line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/></svg>
<div class="label-row"> <span class="settings-rail-label" data-i18n="settings.tab.backup">Backup</span>
<label data-i18n="settings.log_level.label">Log Level</label> <span class="settings-rail-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </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>
<!-- Shutdown action section --> <button class="settings-rail-btn" data-settings-tab="notifications" data-rail-ch="violet" onclick="switchSettingsTab('notifications')" role="tab" aria-label="Notifications" data-i18n-aria-label="settings.tab.notifications">
<div class="form-group"> <svg class="icon" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<div class="label-row"> <span class="settings-rail-label" data-i18n="settings.tab.notifications">Notifications</span>
<label data-i18n="settings.shutdown_action.label">Shutdown action</label> <span class="settings-rail-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.shutdown_action.hint">What happens to LED targets when the server shuts down. "Stop targets" runs the normal stop sequence so devices with auto-restore restore their prior state. "Nothing" leaves the lights showing the last frame.</small>
<select id="settings-shutdown-action">
<option value="stop_targets">Stop targets</option>
<option value="nothing">Nothing</option>
</select>
</div>
<!-- Server Logs button (opens overlay) --> <button class="settings-rail-btn" data-settings-tab="appearance" data-rail-ch="magenta" onclick="switchSettingsTab('appearance')" role="tab" aria-label="Appearance" data-i18n-aria-label="settings.tab.appearance">
<div class="form-group"> <svg class="icon" viewBox="0 0 24 24"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>
<div class="label-row"> <span class="settings-rail-label" data-i18n="settings.tab.appearance">Appearance</span>
<label data-i18n="settings.logs.label">Server Logs</label> <span class="settings-rail-dot" aria-hidden="true"></span>
</div> </button>
<button class="btn btn-secondary" onclick="openLogOverlay()" style="width:100%" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
</div>
<!-- Restart section --> <div class="settings-rail-group" data-i18n="settings.rail.group.system">System</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div>
</div>
<!-- ═══ Backup tab ═══ --> <button class="settings-rail-btn" data-settings-tab="updates" data-rail-ch="signal" onclick="switchSettingsTab('updates')" role="tab" aria-label="Updates" data-i18n-aria-label="settings.tab.updates">
<div id="settings-panel-backup" class="settings-panel"> <svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
<!-- Backup section --> <span class="settings-rail-label" data-i18n="settings.tab.updates">Updates</span>
<div class="form-group"> <span class="settings-rail-badge" id="settings-rail-update-badge" hidden>1</span>
<div class="label-row"> <span class="settings-rail-dot" aria-hidden="true"></span>
<label data-i18n="settings.backup.label">Backup Configuration</label> </button>
<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>
<!-- Restore section --> <button class="settings-rail-btn" data-settings-tab="about" data-rail-ch="amber" onclick="switchSettingsTab('about')" role="tab" aria-label="About" data-i18n-aria-label="settings.tab.about">
<div class="form-group"> <svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
<div class="label-row"> <span class="settings-rail-label" data-i18n="settings.tab.about">About</span>
<label data-i18n="settings.restore.label">Restore Configuration</label> <span class="settings-rail-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> </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=".db" 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="settings-rail-footer" id="settings-rail-build" aria-hidden="true"></div>
<!-- Auto-Backup section --> </nav>
<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;"> <!-- ───────────── PANEL BODY ───────────── -->
<input type="checkbox" id="auto-backup-enabled"> <div class="modal-body settings-body">
<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;"> <!-- ═══ General tab ═══ -->
<div style="flex:1"> <div id="settings-panel-general" class="settings-panel active">
<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%"> <section class="ds-section" data-ch="amber">
<option value="1">1h</option> <div class="ds-section-header">
<option value="6">6h</option> <span class="ds-section-dot" aria-hidden="true"></span>
<option value="12">12h</option> <span class="ds-section-title" data-i18n="settings.section.api_keys">API Keys</span>
<option value="24">24h</option> <span class="ds-section-meta" id="settings-api-keys-meta" hidden></span>
<option value="48">48h</option> <span class="ds-section-index" aria-hidden="true">01</span>
<option value="168">7d</option>
</select>
</div> </div>
<div style="flex:1"> <div class="ds-section-body">
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label> <div class="form-group">
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%"> <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" class="api-key-list"></div>
</div>
</div> </div>
</div> </section>
<div style="display:flex; gap:0.5rem;"> <section class="ds-section" data-ch="cyan">
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="flex:1" data-i18n="settings.auto_backup.save">Save Settings</button> <div class="ds-section-header">
<button class="btn btn-secondary" onclick="triggerBackupNow()" style="flex:1" data-i18n="settings.auto_backup.backup_now">Backup Now</button> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <span class="ds-section-title" data-i18n="settings.section.server">Server</span>
<span class="ds-section-index" aria-hidden="true">02</span>
<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>
<!-- ═══ Notifications tab ═══ -->
<div id="settings-panel-notifications" class="settings-panel">
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.intro_label">Device events</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.notifications.intro_hint">Pick how each device event reaches you. "Snack" shows an in-app toast, "OS" shows a system notification (browser must be granted permission), "Both" shows both, "None" silences the event entirely.</small>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.online">Device came online</label>
</div>
<select id="settings-notif-device-online"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.offline">Device went offline</label>
</div>
<select id="settings-notif-device-offline"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.discovered">New device found</label>
</div>
<select id="settings-notif-device-discovered"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.row.lost">Discovered device lost</label>
</div>
<select id="settings-notif-device-lost"></select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.background.label">Background discovery</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.notifications.background.hint">Continuously scan the LAN (mDNS) and serial bus for new LED devices. Disable to silence "device discovered/lost" events at the source.</small>
<label class="toggle-row">
<input type="checkbox" id="settings-notif-background" checked>
<span data-i18n="settings.notifications.background.toggle">Enable background discovery</span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.notifications.permission.label">OS notification permission</label>
</div>
<div id="settings-notif-permission-row" style="display:flex;gap:0.5rem;align-items:center;">
<span id="settings-notif-permission-state" style="flex:1;font-size:0.9rem;color:var(--text-muted);"></span>
<button class="btn btn-secondary" onclick="requestNotifPermissionFromSettings()" data-i18n="settings.notifications.permission.grant">Grant permission</button>
</div>
</div>
<div class="form-group">
<button class="btn btn-secondary" onclick="testNotifFromSettings()" style="width:100%" data-i18n="settings.notifications.test_button">Send a test notification</button>
</div>
</div>
<!-- ═══ Appearance tab ═══ -->
<div id="settings-panel-appearance" class="settings-panel">
<!-- Rendered dynamically by renderAppearanceTab() -->
</div>
<!-- ═══ Updates tab ═══ -->
<div id="settings-panel-updates" class="settings-panel">
<!-- Current version + status -->
<div class="form-group">
<div class="label-row">
<label data-i18n="update.status_label">Update Status</label>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<span data-i18n="update.current_version">Current version:</span>
<strong id="update-current-version"></strong>
</div>
<div id="update-status-text" style="font-size:0.9rem;font-weight:600;margin-bottom:0.5rem;"></div>
<div id="update-last-check" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:0.3rem;"></div>
<div style="font-size:0.85rem;color:var(--text-muted);margin-bottom:0.75rem;">
<span data-i18n="update.install_type_label">Install type:</span>
<span id="update-install-type"></span>
</div>
<!-- Download progress bar -->
<div style="display:none;margin-bottom:0.5rem;height:4px;background:var(--border-color);border-radius:2px;overflow:hidden;">
<div id="update-progress-bar" style="width:0%;height:100%;background:var(--primary-color);transition:width 0.3s;"></div>
</div>
<div style="display:flex;gap:0.5rem;">
<button id="update-check-btn" class="btn btn-secondary" onclick="checkForUpdates()" style="flex:1">
<span data-i18n="update.check_now">Check for Updates</span>
<span id="update-check-spinner" class="spinner-inline" style="display:none"></span>
</button>
<button id="update-apply-btn" class="btn btn-primary" onclick="applyUpdate()" style="flex:1;display:none" data-i18n="update.apply_now">Update Now</button>
</div>
</div>
<!-- Release notes button -->
<div class="form-group" id="update-release-notes-group" style="display:none">
<button class="btn btn-secondary" onclick="openReleaseNotes()" style="width:100%" data-i18n="update.view_release_notes">View Release Notes</button>
</div>
<!-- Settings -->
<div class="form-group">
<div class="label-row">
<label data-i18n="update.auto_check_label">Auto-Check Settings</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="update.auto_check_hint">Periodically check for new releases in the background.</small>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<input type="checkbox" id="update-enabled">
<label for="update-enabled" style="margin:0" data-i18n="update.enable">Enable auto-check</label>
</div>
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
<div style="flex:1">
<label for="update-interval" style="font-size:0.85rem" data-i18n="update.interval_label">Check interval</label>
<select id="update-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>
<div style="flex:1"> <div class="ds-section-body">
<label for="update-channel" style="font-size:0.85rem" data-i18n="update.channel_label">Channel</label>
<select id="update-channel"> <div class="form-group" id="settings-external-url-group">
<option value="false">Stable</option> <div class="label-row">
<option value="true">Pre-release</option> <label data-i18n="settings.external_url.label">External URL</label>
</select> <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>
<input type="text" id="settings-external-url" placeholder="https://myserver.example.com:8080" data-i18n-placeholder="settings.external_url.placeholder">
</div>
<div class="ds-pair-row">
<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>
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.shutdown_action.label">Shutdown action</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.shutdown_action.hint">What happens to LED targets when the server shuts down. "Stop targets" runs the normal stop sequence so devices with auto-restore restore their prior state. "Nothing" leaves the lights showing the last frame.</small>
<select id="settings-shutdown-action">
<option value="stop_targets">Stop targets</option>
<option value="nothing">Nothing</option>
</select>
</div>
</div>
<div class="save-bar" id="settings-external-url-save-bar" hidden>
<div class="save-bar-msg">
<span data-i18n="settings.save_bar.unsaved">Unsaved changes in</span>
<strong data-i18n="settings.external_url.label">External URL</strong>
</div>
<div class="save-bar-actions">
<button class="btn btn-ghost btn-sm" onclick="revertExternalUrl()" data-i18n="settings.save_bar.revert">Revert</button>
<button class="btn btn-primary btn-sm" onclick="saveExternalUrl()">
<svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
<span data-i18n="settings.save_bar.save">Save</span>
</button>
</div>
</div>
</div> </div>
</div> </section>
<button class="btn btn-primary" onclick="saveUpdateSettings()" style="width:100%" data-i18n="update.save_settings">Save Settings</button> <section class="ds-section" data-ch="coral">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.lifecycle">Lifecycle</span>
<span class="ds-section-meta" data-i18n="settings.section.destructive">DESTRUCTIVE</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.logs.label">Server Logs</div>
<div class="ds-toggle-sub" data-i18n="settings.logs.sub">Live tail of server log output, filterable by level. Opens in a full-screen overlay.</div>
</div>
<button class="btn btn-secondary" onclick="openLogOverlay()" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
</div>
<div class="ds-toggle-row ds-toggle-row--danger">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.restart_server">Restart Server</div>
<div class="ds-toggle-sub" data-i18n="settings.restart.sub">Bounce the LedGrab process. Active capture and connected devices will pause for ~3 seconds.</div>
</div>
<button class="btn btn-danger" onclick="restartServer()">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9"/><polyline points="3 4 3 10 9 10"/></svg>
<span data-i18n="settings.restart.button">Restart</span>
</button>
</div>
</div>
</section>
</div> </div>
</div>
<!-- ═══ About tab ═══ --> <!-- ═══ Backup tab ═══ -->
<div id="settings-panel-about" class="settings-panel"> <div id="settings-panel-backup" class="settings-panel">
<div id="about-panel-content"></div>
</div>
<div id="settings-error" class="error-message" style="display:none;"></div> <section class="ds-section" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.manual">Manual</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.backup.label">Download Backup</div>
<div class="ds-toggle-sub" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</div>
</div>
<button class="btn btn-primary" onclick="downloadBackup()">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<span data-i18n="settings.backup.button">Download</span>
</button>
</div>
<div class="ds-toggle-row ds-toggle-row--danger">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.restore.label">Restore Configuration</div>
<div class="ds-toggle-sub" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</div>
</div>
<input type="file" id="settings-restore-input" accept=".db" style="display:none" onchange="handleRestoreFileSelected(this)">
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
<span data-i18n="settings.restore.button">Restore</span>
</button>
</div>
</div>
</section>
<section class="ds-section" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.auto_backup.label">Auto-Backup</span>
<span class="ds-section-meta" id="auto-backup-status-pill" hidden></span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.auto_backup.enable">Enable auto-backup</div>
<div class="ds-toggle-sub" id="auto-backup-status">&nbsp;</div>
</div>
<label class="settings-switch">
<input type="checkbox" id="auto-backup-enabled">
<span class="settings-switch-track" aria-hidden="true"></span>
</label>
</div>
<div class="ds-pair-row">
<div class="form-group">
<div class="label-row">
<label for="auto-backup-interval" data-i18n="settings.auto_backup.interval_label">Interval</label>
</div>
<select id="auto-backup-interval">
<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 class="form-group">
<div class="label-row">
<label for="auto-backup-max" data-i18n="settings.auto_backup.max_label">Max backups</label>
</div>
<input type="number" id="auto-backup-max" min="1" max="100" value="10">
</div>
</div>
<div class="inline-row inline-row--actions">
<button class="btn btn-secondary" onclick="triggerBackupNow()" style="flex:1">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9"/><polyline points="3 4 3 10 9 10"/></svg>
<span data-i18n="settings.auto_backup.backup_now">Backup Now</span>
</button>
</div>
</div>
</section>
<section class="ds-section" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.saved_backups.label">Saved Backups</span>
<span class="ds-section-meta" id="saved-backups-meta" hidden></span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<small class="input-hint" 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" class="backup-list"></div>
</div>
</section>
</div>
<!-- ═══ Notifications tab ═══ -->
<div id="settings-panel-notifications" class="settings-panel">
<section class="ds-section" data-ch="violet">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.notif_channels">Channels</span>
<span class="ds-section-meta" data-i18n="settings.notif_matrix.event_count">4 EVENTS</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<small class="input-hint" style="display:block; margin-bottom:10px" data-i18n="settings.notifications.intro_hint">Pick how each device event reaches you. "Snack" shows an in-app toast, "OS" shows a system notification (browser must be granted permission), "Both" shows both, "None" silences the event entirely.</small>
<!-- Visual matrix — synced both ways with the hidden <select>s below. -->
<div class="notif-matrix" id="settings-notif-matrix" role="grid" aria-label="Notification preferences matrix">
<div class="notif-matrix-cell notif-matrix-head" role="columnheader" data-i18n="settings.notif_matrix.col.event">Event</div>
<div class="notif-matrix-cell notif-matrix-head" role="columnheader" data-i18n="settings.notifications.channel.snack.label">Snack</div>
<div class="notif-matrix-cell notif-matrix-head" role="columnheader" data-i18n="settings.notifications.channel.os.label">OS</div>
<div class="notif-matrix-cell notif-matrix-head" role="columnheader" data-i18n="settings.notifications.channel.both.label">Both</div>
<div class="notif-matrix-cell notif-matrix-head" role="columnheader" data-i18n="settings.notifications.channel.none.label">None</div>
<div class="notif-matrix-cell notif-matrix-row-label" role="rowheader">
<svg class="icon" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<span data-i18n="settings.notifications.row.online">Device came online</span>
</div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_online" data-channel="snack" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_online" data-channel="os" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_online" data-channel="both" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_online" data-channel="none" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-row-label" role="rowheader">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
<span data-i18n="settings.notifications.row.offline">Device went offline</span>
</div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_offline" data-channel="snack" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_offline" data-channel="os" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_offline" data-channel="both" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_offline" data-channel="none" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-row-label" role="rowheader">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span data-i18n="settings.notifications.row.discovered">New device found</span>
</div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_discovered" data-channel="snack" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_discovered" data-channel="os" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_discovered" data-channel="both" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_discovered" data-channel="none" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-row-label" role="rowheader">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>
<span data-i18n="settings.notifications.row.lost">Discovered device lost</span>
</div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_lost" data-channel="snack" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_lost" data-channel="os" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_lost" data-channel="both" tabindex="0"><span class="notif-matrix-dot"></span></div>
<div class="notif-matrix-cell notif-matrix-opt" role="gridcell" data-event="device_lost" data-channel="none" tabindex="0"><span class="notif-matrix-dot"></span></div>
</div>
<!-- Hidden underlying <select>s — settings.ts attaches IconSelect AND
our matrix wiring keeps both in sync. Kept in DOM for backwards
compat with notifications-watcher.ts. -->
<div class="notif-hidden-selects" hidden>
<select id="settings-notif-device-online" data-notif-event="device_online"></select>
<select id="settings-notif-device-offline" data-notif-event="device_offline"></select>
<select id="settings-notif-device-discovered" data-notif-event="device_discovered"></select>
<select id="settings-notif-device-lost" data-notif-event="device_lost"></select>
</div>
</div>
</section>
<section class="ds-section" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.notif_discovery">Discovery</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.notifications.background.label">Background discovery</div>
<div class="ds-toggle-sub" data-i18n="settings.notifications.background.hint">Continuously scan the LAN (mDNS) and serial bus for new LED devices. Disable to silence "device discovered/lost" events at the source. Restart the server to apply.</div>
</div>
<label class="settings-switch">
<input type="checkbox" id="settings-notif-background" checked>
<span class="settings-switch-track" aria-hidden="true"></span>
</label>
</div>
</div>
</section>
<section class="ds-section" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.notif_permission">OS Permission</span>
<span class="ds-section-meta" id="settings-notif-permission-pill" hidden></span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row" id="settings-notif-permission-row">
<div class="ds-toggle-text">
<div class="label-row">
<div class="ds-toggle-title" data-i18n="settings.notifications.permission.label">OS notification permission</div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.notifications.permission.hint">The browser controls OS notification permission per site. Once denied, LedGrab can no longer ask again — you need to clear it in the browser. Click the site icon (lock) in the address bar → Site settings → Notifications → Allow, then reload.</small>
<div class="ds-toggle-sub" id="settings-notif-permission-state">&nbsp;</div>
</div>
<button class="btn btn-secondary" onclick="requestNotifPermissionFromSettings()" data-i18n="settings.notifications.permission.grant">Grant permission</button>
</div>
<div class="inline-row inline-row--actions">
<button class="btn btn-secondary" onclick="testNotifFromSettings()" style="flex:1" data-i18n="settings.notifications.test_button">Send a test notification</button>
</div>
</div>
</section>
</div>
<!-- ═══ Appearance tab ═══ -->
<div id="settings-panel-appearance" class="settings-panel">
<!-- Rendered dynamically by renderAppearanceTab() -->
</div>
<!-- ═══ Updates tab ═══ -->
<div id="settings-panel-updates" class="settings-panel">
<section class="ds-section" id="update-status-section" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="update.status_label">Update Status</span>
<span class="ds-section-meta" id="update-status-meta" hidden></span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="status-card" id="update-status-card">
<div class="status-card-icon" aria-hidden="true">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
</div>
<div class="status-card-text">
<div class="status-card-main" id="update-status-text"></div>
<div class="status-card-sub">
<div class="status-card-line">
<span data-i18n="update.current_version">Current version:</span>
<strong id="update-current-version"></strong>
</div>
<div class="status-card-line">
<span data-i18n="update.install_type_label">Install type:</span>
<span id="update-install-type"></span>
</div>
<div class="status-card-line" id="update-last-check"></div>
</div>
</div>
<div class="status-card-actions">
<button id="update-check-btn" class="btn btn-secondary" onclick="checkForUpdates()">
<span data-i18n="update.check_now">Check for Updates</span>
<span id="update-check-spinner" class="spinner-inline" style="display:none"></span>
</button>
<button id="update-apply-btn" class="btn btn-primary" onclick="applyUpdate()" style="display:none" data-i18n="update.apply_now">Update Now</button>
</div>
</div>
<!-- Download progress bar -->
<div class="update-progress" id="update-progress-wrap" style="display:none">
<div id="update-progress-bar"></div>
</div>
<!-- Release notes — surfaced as a rack-style toggle-row -->
<div class="ds-toggle-row" id="update-release-notes-group" style="display:none">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="update.view_release_notes">View Release Notes</div>
<div class="ds-toggle-sub" data-i18n="update.release_notes_hint">What's new in the available version — read the changelog before applying.</div>
</div>
<button class="btn btn-secondary" onclick="openReleaseNotes()">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span data-i18n="update.release_notes_open">Open</span>
</button>
</div>
</div>
</section>
<section class="ds-section" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="update.auto_check_label">Auto-Check Settings</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="update.enable">Enable auto-check</div>
<div class="ds-toggle-sub" data-i18n="update.auto_check_hint">Periodically check for new releases in the background.</div>
</div>
<label class="settings-switch">
<input type="checkbox" id="update-enabled">
<span class="settings-switch-track" aria-hidden="true"></span>
</label>
</div>
<div class="ds-pair-row">
<div class="form-group">
<div class="label-row">
<label for="update-interval" data-i18n="update.interval_label">Check interval</label>
</div>
<select id="update-interval">
<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 class="form-group">
<div class="label-row">
<label for="update-channel" data-i18n="update.channel_label">Channel</label>
</div>
<select id="update-channel">
<option value="false">Stable</option>
<option value="true">Pre-release</option>
</select>
</div>
</div>
</div>
</section>
</div>
<!-- ═══ About tab ═══ -->
<div id="settings-panel-about" class="settings-panel">
<div id="about-panel-content"></div>
</div>
<div id="settings-error" class="error-message" style="display:none;"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Log Viewer Overlay (full-screen, independent of settings modal) --> <!-- Log Viewer Overlay (full-screen, independent of settings modal) -->
<div id="log-overlay" class="log-overlay" style="display:none;"> <div id="log-overlay" class="log-overlay" style="display:none;" data-ch="cyan">
<button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close" aria-label="Close">
<div class="log-overlay-toolbar"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<h3 data-i18n="settings.logs.label">Server Logs</h3> </button>
<select id="log-viewer-filter" onchange="applyLogFilter()">
<option value="all" data-i18n="settings.logs.filter.all">All levels</option> <article class="module log-module" data-ch="cyan">
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option> <div class="mod-head">
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option> <div class="mod-id">
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option> <span class="mod-badge">LOG · STREAM</span>
</select> <div class="mod-name"><span data-i18n="settings.logs.label">Server Logs</span></div>
<button id="log-viewer-connect-btn" class="btn btn-secondary btn-sm" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button> <div class="mod-meta">WebSocket · /api/v1/system/logs/ws</div>
<button class="btn btn-secondary btn-sm" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button> </div>
<div class="mod-leds" id="log-viewer-leds" aria-hidden="true">
<span class="led" data-role="link"></span>
<span class="led" data-role="data"></span>
<span class="led" data-role="data"></span>
</div>
</div>
<div class="mod-metrics">
<div class="mod-metric">
<div class="k" data-i18n="settings.logs.stat.lines">LINES</div>
<div class="v" id="log-stat-total">0</div>
</div>
<div class="mod-metric" id="log-stat-warn-cell">
<div class="k">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-i18n="settings.logs.stat.warn">WARN</span>
</div>
<div class="v" id="log-stat-warn">0</div>
</div>
<div class="mod-metric" id="log-stat-err-cell">
<div class="k">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-i18n="settings.logs.stat.err">ERR</span>
</div>
<div class="v" id="log-stat-err">0</div>
</div>
</div>
<div class="mod-foot log-mod-foot">
<div class="mod-patch" id="log-patch-indicator">
<span class="patch-dot"></span>
<span id="log-patch-label" data-i18n="settings.logs.patch.idle">STANDBY</span>
</div>
<select id="log-viewer-filter" onchange="applyLogFilter()" class="log-filter-select">
<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="mod-btn mod-btn-go" onclick="connectLogViewer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span data-i18n="settings.logs.connect">Connect</span>
</button>
<button class="mod-btn" onclick="clearLogViewer()" title="Clear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
<span data-i18n="settings.logs.clear">Clear</span>
</button>
</div>
</article>
<div class="log-console" data-ch="cyan">
<div class="log-console__rail" aria-hidden="true">
<span class="log-console__caret">&#x25B8;</span>
<span class="log-console__rail-label">TAIL</span>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
<div class="log-console__empty" id="log-viewer-empty">
<div class="log-console__empty-mark" aria-hidden="true"></div>
<div class="log-console__empty-title" data-i18n="settings.logs.empty.title">Awaiting log frames</div>
<div class="log-console__empty-sub" data-i18n="settings.logs.empty.sub">Connect the WebSocket stream to begin tailing.</div>
</div>
</div> </div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div> </div>
<!-- Release Notes Overlay (full-screen, same pattern as log overlay) --> <!-- Release Notes Overlay — v2 "instrument readout" aesthetic, scoped via .release-notes-shell -->
<div id="release-notes-overlay" class="log-overlay" style="display:none;"> <div id="release-notes-overlay" class="log-overlay release-notes-shell" style="display:none;" data-ch="signal">
<button class="log-overlay-close" onclick="closeReleaseNotes()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <span class="rn-shell__stripe" aria-hidden="true"></span>
<div class="log-overlay-toolbar"> <span class="rn-shell__bracket rn-shell__bracket--tl" aria-hidden="true"></span>
<h3 data-i18n="update.release_notes">Release Notes</h3> <span class="rn-shell__bracket rn-shell__bracket--br" aria-hidden="true"></span>
<header class="rn-head">
<div class="rn-head__lede">
<div class="rn-eyebrow">
<span class="rn-eyebrow__dot" aria-hidden="true"></span>
<span class="rn-eyebrow__text" data-i18n="update.release_notes">Release Notes</span>
<span class="rn-eyebrow__sep" aria-hidden="true"></span>
<span class="rn-eyebrow__channel" id="release-notes-channel">CHANGELOG</span>
</div>
<h2 class="rn-title">
<span class="rn-title__main" id="release-notes-name">Release Notes</span>
<em class="rn-title__accent" id="release-notes-version" hidden></em>
</h2>
<div class="rn-meta" id="release-notes-meta" hidden>
<span class="rn-chip" id="release-notes-tag-chip" hidden>
<span class="rn-chip__dot" aria-hidden="true"></span>
<span class="rn-chip__k">TAG</span>
<span class="rn-chip__v" id="release-notes-tag"></span>
</span>
<span class="rn-chip" id="release-notes-date-chip" hidden>
<span class="rn-chip__k">PUBLISHED</span>
<span class="rn-chip__v" id="release-notes-date"></span>
</span>
<span class="rn-chip rn-chip--pre" id="release-notes-pre-chip" hidden>
<span class="rn-chip__k" data-i18n="update.prerelease">PRE-RELEASE</span>
</span>
</div>
</div>
<div class="rn-head__actions">
<a class="rn-act rn-act--link" id="release-notes-external" href="#" target="_blank" rel="noopener"
hidden data-i18n-title="update.view_release" title="View Release">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button class="rn-act rn-act--close" type="button" onclick="closeReleaseNotes()"
data-i18n-aria-label="aria.close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</header>
<div class="rn-body">
<div id="release-notes-content" class="release-notes-content"></div>
</div> </div>
<div id="release-notes-content" class="release-notes-content"></div>
</div> </div>
+7
View File
@@ -24,6 +24,13 @@ _test_tmp = Path(tempfile.mkdtemp(prefix="wled_test_"))
_test_db_path = str(_test_tmp / "test_ledgrab.db") _test_db_path = str(_test_tmp / "test_ledgrab.db")
_test_assets_dir = str(_test_tmp / "test_assets") _test_assets_dir = str(_test_tmp / "test_assets")
# Pre-create the test database file so main.py's legacy-data migration
# (which copies the user's production DB into the configured location when
# it doesn't exist) doesn't shovel real data into the test DB. Without this
# touch, tests see production state — settings, devices, notification
# preferences, history — and assertions about "default" state fail.
Path(_test_db_path).touch()
_original_config = _config_mod.Config.load() _original_config = _config_mod.Config.load()
_test_config = _original_config.model_copy( _test_config = _original_config.model_copy(
update={ update={
@@ -35,8 +35,15 @@ def _full_prefs() -> dict:
def test_get_returns_defaults_when_unset(client): def test_get_returns_defaults_when_unset(client):
"""When no prefs have been saved, GET returns the documented defaults.""" """When no prefs have been saved, GET returns the documented defaults."""
# Wipe via PUT to a known state to make this order-independent. # Wipe the stored row to a falsy value so this test is independent of
# (No DELETE endpoint — settings rows are scalar.) # any prior test in the suite that may have PUT a customised matrix.
# `load_notification_preferences` falls back to schema defaults when
# the stored value is empty / falsy.
from ledgrab.api.dependencies import get_database
db = get_database()
db.set_setting("notification_preferences", {})
resp = client.get("/api/v1/preferences/notifications") resp = client.get("/api/v1/preferences/notifications")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()