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
Thumbs.db
.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
- Update documentation when changing behavior
- 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
await websocket.send_text(_json.dumps(meta))
# For api_input: send the current buffer immediately so the client
# gets a frame right away (fallback color if inactive) rather than
# leaving the canvas blank/stale until external data arrives.
# For api_input: only send an initial frame if a client has actually
# pushed data (push_generation > 0). Without prior data, the preview
# stays blank instead of showing the fallback buffer as a stray frame.
if is_api_input:
initial_colors = stream.get_latest_colors()
if initial_colors is not None:
await websocket.send_bytes(initial_colors.tobytes())
initial_gen = stream.push_generation
if initial_gen > 0:
_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
_frame_live = None
+9
View File
@@ -3,6 +3,14 @@
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):
version: str
tag: str
@@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel):
body: str
prerelease: bool
published_at: str
assets: list[UpdateAssetInfo] = Field(default_factory=list)
class UpdateStatusResponse(BaseModel):
@@ -45,6 +45,19 @@ class ColorStripStream(ABC):
def target_fps(self) -> int:
"""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
@abstractmethod
def led_count(self) -> int:
@@ -2,6 +2,7 @@
import threading
import time
from collections import deque
from typing import Optional
import numpy as np
@@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream):
self._thread: Optional[threading.Thread] = None
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
def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
@@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream):
def target_fps(self) -> int:
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
def led_count(self) -> int:
return self._led_count
@@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None
self._latest_colors = None
self._previous_colors = None
self._new_frame_timestamps.clear()
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream):
cached_frame = frame
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
mapper = self._pixel_mapper
@@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream):
def target_fps(self) -> int:
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:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@@ -75,6 +75,16 @@ class MetricsHistory:
self._system: deque = deque(maxlen=MAX_SAMPLES)
self._targets: Dict[str, deque] = {}
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):
"""Start the background sampling loop."""
@@ -110,7 +120,6 @@ class MetricsHistory:
"""Collect one snapshot of system and target metrics."""
# System metrics (blocking psutil/nvml calls in thread pool)
sys_snap = await asyncio.to_thread(_collect_system_snapshot)
self._system.append(sys_snap)
# Per-target metrics from processor states
try:
@@ -121,22 +130,151 @@ class MetricsHistory:
now = datetime.now(timezone.utc).isoformat()
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():
active_ids.add(target_id)
if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
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(
{
"t": now,
"fps": state.get("fps_actual"),
"fps": fps_actual,
"fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"),
"fps_target": fps_target,
"timing": state.get("timing_total_ms"),
"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
for tid in list(self._targets.keys()):
if tid not in active_ids:
@@ -69,6 +69,13 @@ class ProcessingMetrics:
# Streaming liveness (HTTP probe during DDP)
device_streaming_reachable: Optional[bool] = None
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
@@ -400,9 +400,13 @@ class WledTargetProcessor(TargetProcessor):
css_timing: dict = {}
css_capture_fps: Optional[int] = None
css_capture_fps_actual: Optional[float] = None
if self._is_running and self._css_stream is not None:
css_timing = self._css_stream.get_last_timing()
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
# Picture source timing
@@ -447,6 +451,8 @@ class WledTargetProcessor(TargetProcessor):
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": fps_target,
"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_keepalive": metrics.frames_keepalive 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)
else:
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
@staticmethod
@@ -588,6 +588,14 @@ class UpdateService:
"body": rel.body,
"prerelease": rel.prerelease,
"published_at": rel.published_at,
"assets": [
{
"name": a.name,
"size": a.size,
"download_url": a.download_url,
}
for a in rel.assets
],
}
if rel
else None
+29 -22
View File
@@ -18,39 +18,45 @@ h1 {
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 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
margin-top: 6px;
}
/* ─── Preset card (shared) ─── */
.ap-card {
--ap-ch: var(--ch-magenta, #ff4ade);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--card-bg);
align-items: stretch;
gap: 5px;
padding: 5px 5px 4px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 8px);
background: var(--lux-bg-1, var(--card-bg));
cursor: pointer;
transition: border-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); }
.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);
}
.ap-card.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color),
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
border: 2px solid var(--ap-ch);
padding: 4px 4px 3px;
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 {
@@ -60,29 +66,30 @@ h1 {
right: 6px;
font-size: 0.65rem;
font-weight: 700;
color: var(--primary-color);
color: var(--ap-ch);
}
.ap-card-label {
font-size: 0.72rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
color: var(--lux-ink-dim, var(--text-secondary));
text-align: center;
line-height: 1.2;
letter-spacing: 0.02em;
}
.ap-card.active .ap-card-label {
color: var(--primary-color);
color: var(--ap-ch);
}
/* ─── Style preset preview ─── */
.ap-card-preview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px);
border: 1px solid;
padding: 8px 7px 6px;
padding: 7px 6px 5px;
display: flex;
flex-direction: column;
gap: 4px;
@@ -113,12 +120,12 @@ h1 {
.ap-bg-preview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px);
overflow: hidden;
position: relative;
background: var(--bg-color);
border: 1px solid var(--border-color);
border: 1px solid var(--lux-line, var(--border-color));
}
.ap-bg-preview-inner {
+13 -38
View File
@@ -1,48 +1,23 @@
/* ===== 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 {
opacity: 0.6;
}
.automation-logic-label {
font-size: 0.7rem;
/* Chain-arrow separator — slips between chips on the AUTO card to
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;
color: var(--text-muted);
padding: 0 4px;
}
/* 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;
letter-spacing: 0.04em;
user-select: none;
flex-shrink: 0;
}
/* Automation rule editor rows */
+25
View File
@@ -2294,6 +2294,31 @@ ul.section-tip li {
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 ──────────────────────────────────────────── */
.mod-desc {
@@ -94,6 +94,19 @@
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 {
min-width: auto;
padding: 7px 10px;
@@ -430,7 +430,7 @@ html:has(#tab-graph.active) {
}
.graph-node-body {
fill: var(--card-bg);
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--lux-line, var(--border-color));
stroke-width: 1;
rx: 6;
@@ -723,7 +723,7 @@ html:has(#tab-graph.active) {
}
.graph-node-overlay-bg {
fill: var(--card-bg);
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--border-color);
stroke-width: 1;
rx: 6;
+7 -6
View File
@@ -382,26 +382,27 @@ h2 {
font-family: var(--font-mono, 'Orbitron', sans-serif);
font-size: 0.55rem;
font-weight: 600;
color: var(--lux-ink-mute, var(--text-secondary));
color: var(--ch-signal, var(--primary-color));
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;
border-radius: 2px;
letter-spacing: 0.12em;
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 {
background: var(--warning-color);
background: var(--ch-signal, var(--primary-color));
border-color: var(--ch-signal, var(--primary-color));
color: #fff;
cursor: pointer;
animation: updatePulse 2s ease-in-out infinite;
}
@keyframes updatePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
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 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); }
}
/* ── Update banner ── */
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@
.template-card {
--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-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
+5 -3
View File
@@ -106,7 +106,7 @@ import {
} from './features/integrations.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset,
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
@@ -224,7 +224,7 @@ import {
loadLogLevel, setLogLevel,
loadShutdownAction, setShutdownAction,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, getBaseOrigin, loadExternalUrl,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -416,7 +416,7 @@ Object.assign(window, {
deleteAutomation,
copyWebhookUrl,
// scene presets (modal buttons stay on window; card actions migrated to event delegation)
// scene presets modal buttons + mod-card inline handlers
openScenePresetCapture,
editScenePreset,
saveScenePreset,
@@ -424,6 +424,7 @@ Object.assign(window, {
activateScenePreset,
cloneScenePreset,
deleteScenePreset,
recaptureScenePreset,
addSceneTarget,
// integrations
@@ -630,6 +631,7 @@ Object.assign(window, {
requestNotifPermissionFromSettings,
testNotifFromSettings,
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
// update
+139 -14
View File
@@ -16,14 +16,136 @@ export function desktopFocus(el: HTMLElement | null) {
if (el && !isTouchDevice()) el.focus();
}
export function toggleHint(btn: HTMLElement) {
const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null;
if (hint && hint.classList.contains('input-hint')) {
const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block';
btn.classList.toggle('active', !visible);
btn.setAttribute('aria-expanded', String(!visible));
/* Hint popover
The legacy implementation toggled the inline `<small class="input-hint">`
between display:none and display:block. That worked but pushed every
field below it down every help click reflowed half the modal. The
popover variant anchors a floating tooltip to the `?` button so the
form layout stays stable. The inline `<small>` is kept in the DOM
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"])';
@@ -433,12 +555,15 @@ export function formatCompact(n: number | null | undefined) {
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 '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return t('time.hours_minutes', { h, m });
if (m > 0) return t('time.minutes_seconds', { m, s });
return t('time.seconds', { s });
const total = Math.floor(seconds);
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
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 {
const panel = document.getElementById('settings-panel-appearance');
if (!panel) return;
// Don't re-render if already populated
if (panel.querySelector('.appearance-presets')) {
// Don't re-render if already populated — just refresh selections + meta pills
if (panel.querySelector('.ds-section')) {
_updatePresetSelection('style', _activeStyleId);
_updatePresetSelection('bg', _activeBgEffectId);
_updateAppearanceMetaPills();
return;
}
@@ -476,18 +480,46 @@ export function renderAppearanceTab(): void {
}).join('');
panel.innerHTML = `
<div class="appearance-presets">
<div class="form-group">
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label>
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
<section class="ds-section" data-ch="magenta">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<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>
<div class="form-group" style="margin-top:1rem">
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label>
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
</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="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>
</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. */
@@ -549,12 +581,13 @@ function _ensureFont(url: string, id: string): void {
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 {
const attr = type === 'style' ? 'style' : 'bg';
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
});
_updateAppearanceMetaPills();
}
// ─── 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 * as P from '../core/icon-paths.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 { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts';
@@ -136,39 +137,49 @@ function getAssetTypeLabel(assetType: string): string {
// ── Card builder ──
export function createAssetCard(asset: Asset): string {
const icon = getAssetTypeIcon(asset.asset_type);
const sizeStr = formatFileSize(asset.size_bytes);
const prebuiltBadge = asset.prebuilt
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
: '';
const typeLabel = getAssetTypeLabel(asset.asset_type);
let playBtn = '';
if (asset.asset_type === 'sound') {
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
const badgeText = `ASSET · ${asset.asset_type.slice(0, 3).toUpperCase()}`;
const chips: ModChipOpts[] = [
{ 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({
dataAttr: 'data-id',
id: asset.id,
removeOnclick: `deleteAsset('${asset.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(asset.name)}">
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
${prebuiltBadge}
</div>
${renderTagChips(asset.tags)}`,
actions: `
${playBtn}
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
});
const iconActions: any[] = [];
if (asset.asset_type === 'sound') {
iconActions.push({ icon: ICON_PLAY_SOUND, onclick: '', title: t('asset.play'), dataAttrs: { 'data-action': 'play' } });
}
iconActions.push({ icon: ICON_DOWNLOAD, onclick: '', title: t('asset.download'), dataAttrs: { 'data-action': 'download' } });
iconActions.push({ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } });
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: asset.name,
metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`),
leds: ['on'],
menu: {
hideOnclick: `toggleCardHidden('assets','${asset.id}')`,
deleteOnclick: `deleteAsset('${asset.id}')`,
},
},
body: {
chips,
},
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 ──
@@ -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 { FilterListManager } from '../core/filter-list.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts } from '../core/mod-card.ts';
import { loadPictureSources } from './streams.ts';
// ── Module state ─────────────────────────────────────────────
@@ -266,34 +267,44 @@ export function renderAPTModalFilterList() { aptFilterManager.render(); }
// ── Card rendering (used by streams.ts) ───────────────────────
export function createAudioProcessingTemplateCard(tmpl: any): string {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map((fi: any) => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getAudioFilterName(fi.filter_id);
if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) {
const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
return wrapCard({
type: 'template-card',
dataAttr: 'data-apt-id',
id: tmpl.id,
removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAudioProcessingTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioProcessingTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL \u00b7 AUDIO PROC' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`),
leds: ['off'],
menu: {
duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`,
deleteOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
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 { updateTabBadge, updateSubTabHash } from './tabs.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 { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getBaseOrigin } from './settings.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> = {
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
/* Build one chip per automation rule. The chip shows the rule type's
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) => {
const apps = (c.apps || []).join(', ');
const apps = (c.apps || []).join(', ') || '—';
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) => {
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) => {
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>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
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>`,
mqtt: (c) => ({
icon: ICON_RADIO,
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()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
// ── Rule chips: one per rule, joined by chain-arrow separators
// (`+` 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 = '';
if (automation.rules.length === 0) {
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>`);
}
const logicGlyph = automation.rule_logic === 'and' ? '+' : 'OR';
const ruleChain = ruleChips.map(_chipHtml).join(_chainArrow(logicGlyph));
// 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 sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
const sceneChipHtml = _chipHtml(scene ? {
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
let deactivationMeta = '';
// ── Optional deactivation chip — `↩` revert or fallback scene.
// 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') {
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') {
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
if (fallback) {
const fbColor = fallback.color || '#4fc3f7';
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>`;
} else {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}</span>`;
}
deactivationHtml = _chainArrow('↩') + _chipHtml(fallback ? {
icon: ICON_UNDO,
text: fallback.name,
title: t('automations.deactivation_mode.fallback_scene'),
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) {
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',
id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
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>`,
mod,
});
const tagsHtml = renderTagChips(automation.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
export async function openAutomationEditor(automationId?: any, cloneData?: any) {
@@ -18,6 +18,7 @@ import {
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE,
} from '../../core/icons.ts';
import { wrapCard } from '../../core/card-colors.ts';
import type { ModCardOpts } from '../../core/mod-card.ts';
import type { ColorStripSource } from '../../types.ts';
import { bindableValue, bindableColor } from '../../types.ts';
import { renderTagChips } from '../../core/tag-input.ts';
@@ -273,6 +274,25 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
/* ── 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>) {
// Clock crosslink badge
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 })
: _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 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 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({
dataAttr: 'data-css-id',
id: source.id,
removeOnclick: `deleteColorStrip('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(source.name)}">
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span>
</div>
</div>
<div class="stream-card-props">
${propsHtml}
</div>
${renderTagChips(source.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
});
const iconActions: any[] = [];
iconActions.push({ icon: ICON_TEST, onclick: `event.stopPropagation(); testColorStrip('${source.id}')`, title: t('color_strip.test.title') });
if (isPictureKind) {
const calibrationOnclick = source.source_type === 'picture_advanced'
? `showAdvancedCalibration('${source.id}')`
: `showCSSCalibration('${source.id}')`;
iconActions.push({ icon: ICON_CALIBRATION, onclick: calibrationOnclick, title: t('calibration.title') });
iconActions.push({ icon: ICON_OVERLAY, onclick: `event.stopPropagation(); toggleCSSOverlay('${source.id}')`, title: t('overlay.toggle') });
}
if (isKeyColors) {
iconActions.push({ icon: ICON_PATTERN_TEMPLATE, onclick: `event.stopPropagation(); configureKCRegions('${source.id}')`, title: t('color_strip.key_colors.configure_regions') });
}
if (isNotification) {
iconActions.push({ icon: ICON_BELL, onclick: `event.stopPropagation(); testNotification('${source.id}')`, title: t('color_strip.notification.test') });
iconActions.push({ icon: ICON_AUTOMATION, onclick: `event.stopPropagation(); showNotificationHistory()`, title: t('color_strip.notification.history.title') });
}
iconActions.push({ icon: ICON_EDIT, onclick: `showCSSEditor('${source.id}')`, title: t('common.edit') });
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',
fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps',
capture_fps_actual: 'dashboard.perf.total_capture_fps_actual',
errors: 'dashboard.perf.errors',
devices: 'dashboard.perf.devices',
cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram',
gpu: 'dashboard.perf.gpu',
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;
@@ -36,14 +36,17 @@ export type PerfCellKey =
| 'patches'
| 'fps'
| 'capture_fps'
| 'capture_fps_actual'
| 'errors'
| 'devices'
| 'cpu'
| 'ram'
| 'gpu'
| 'temp'
// Reserved.
| 'network'
| 'device_latency'
| 'send_timing'
// Reserved.
| 'disk'
| 'audio-peak';
@@ -145,12 +148,16 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultPerfCell('patches'),
_defaultPerfCell('fps'),
_defaultPerfCell('capture_fps'),
_defaultPerfCell('capture_fps_actual', false),
_defaultPerfCell('errors'),
_defaultPerfCell('devices'),
_defaultPerfCell('cpu'),
_defaultPerfCell('ram'),
_defaultPerfCell('gpu'),
_defaultPerfCell('temp', false),
_defaultPerfCell('network', false),
_defaultPerfCell('device_latency', false),
_defaultPerfCell('send_timing', false),
],
global: {
width: 'full',
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.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 { isActiveTab } from '../core/tab-registry.ts';
import {
@@ -39,6 +39,11 @@ let _fpsCurrentHistory: Record<string, number[]> = {};
let _fpsCharts: Record<string, any> = {};
let _lastRunningIds: 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 _uptimeTimer: ReturnType<typeof setInterval> | null = null;
let _uptimeElements: Record<string, Element> = {};
@@ -99,10 +104,6 @@ function _startUptimeTimer(): void {
if (!el) continue;
const seconds = _getInterpolatedUptime(id);
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);
}
}
@@ -601,6 +602,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[];
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 */ }
}
@@ -656,6 +683,13 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
let fpsSum = 0;
let fpsTargetSum = 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) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: 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);
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 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 captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount);
// Errors / dropped frames — fed cumulative totals; the perf
// 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.
let totalErrors = 0;
let totalSkipped = 0;
let totalBytesSent = 0;
let sendTimingSum = 0;
let sendTimingMax = 0;
let sendTimingCount = 0;
for (const r of running) {
const e = r.metrics?.errors_count;
if (typeof e === 'number' && e > 0) totalErrors += e;
const s = r.state?.frames_skipped;
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);
// 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)
const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(',');
@@ -23,6 +23,30 @@ import type { Device } from '../types.ts';
let _deviceTagsInput: 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() {
const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (!sel) return;
@@ -624,6 +648,7 @@ export async function showSettings(deviceId: any) {
const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null;
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
_updateSettingsSectionVisibility();
settingsModal.snapshot();
settingsModal.open();
@@ -4,7 +4,7 @@
*/
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 ─────────────────────────────────────────────────
@@ -61,40 +61,48 @@ export function snoozeDonation(): void {
_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 {
const container = document.getElementById('about-panel-content');
if (!container) return;
const version = document.getElementById('version-number')?.textContent || '';
let links = '';
const version = document.getElementById('version-number')?.textContent?.trim() || '';
const linkButtons: string[] = [];
if (_repoUrl) {
links += `<a href="${_repoUrl}" target="_blank" rel="noopener" class="about-link">
${ICON_GITHUB}
<span>${t('donation.view_source')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
linkButtons.push(
`<a href="${_repoUrl}" target="_blank" rel="noopener" class="btn">
${ICON_GITHUB}
<span>${t('donation.view_source')}</span>
</a>`,
);
}
if (_donateUrl) {
links += `<a href="${_donateUrl}" target="_blank" rel="noopener" class="about-link about-link-donate">
${ICON_HEART}
<span>${t('donation.about_donate')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
linkButtons.push(
`<a href="${_donateUrl}" target="_blank" rel="noopener" class="btn">
${ICON_HEART}
<span>${t('donation.about_donate')}</span>
</a>`,
);
}
container.innerHTML = `
<div class="about-section">
<div class="about-logo">${ICON_HEART}</div>
<h3 class="about-title">${t('donation.about_title')}</h3>
${version ? `<span class="about-version">${version}</span>` : ''}
<p class="about-text">${t('donation.about_opensource')}</p>
${links ? `<div class="about-links">${links}</div>` : ''}
<p class="about-license">${t('donation.about_license')}</p>
</div>
<section class="ds-section" data-ch="amber">
<div class="ds-section-body">
<div class="about-hero">
<div class="about-mark" aria-hidden="true">L</div>
<div class="about-name">${t('donation.about_title')}</div>
${version ? `<div class="about-version">${version}</div>` : ''}
<div class="about-tag">${t('donation.about_opensource')}</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 { CardSection } from '../core/card-sections.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 { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import {
@@ -540,34 +541,54 @@ export function testGameConnection() {
// ── Card renderer ──
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 enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
const mappingCount = gi.event_mappings?.length || 0;
const isEnabled = !!gi.enabled;
return wrapCard({
type: 'template-card',
dataAttr: 'data-gi-id',
id: gi.id,
removeOnclick: `deleteGameIntegration('${gi.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
</div>
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
<div class="stream-card-props">
<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>` : ''}
</div>
${renderTagChips(gi.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
// Badge: GAME · {ADAPTER} — adapter type compressed to 4-6 chars uppercase
const adapterBadge = String(gi.adapter_type).toUpperCase().slice(0, 8);
const badgeText = `GAME · ${adapterBadge}`;
const leds: LedState[] = isEnabled ? ['on'] : ['off'];
const chips: ModChipOpts[] = [
{ icon: ICON_GAMEPAD, text: adapterName, title: t('game_integration.adapter') },
{ icon: ICON_CIRCLE_DOT, text: enabledLabel, title: t('game_integration.status'), variant: isEnabled ? 'tag' : 'default' },
];
if (mappingCount > 0) {
chips.push({ icon: _icon(P.listChecks), text: `${mappingCount} ${t('game_integration.mappings') || 'mappings'}`, title: t('game_integration.mappings') });
}
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: gi.name,
metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`),
leds,
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 ──
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_REFRESH } from '../core/icons.ts';
import * as P from '../core/icon-paths.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 type { HomeAssistantSource } from '../types.ts';
@@ -217,44 +218,51 @@ export async function testHASource(): Promise<void> {
// ── Card rendering ──
export function createHASourceCard(source: HomeAssistantSource) {
let healthClass: string, healthTitle: string;
if (source.connected) {
healthClass = 'health-online';
healthTitle = `${t('ha_source.connected')}${source.entity_count} entities`;
} else {
healthClass = 'health-offline';
healthTitle = t('ha_source.disconnected');
}
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const isConnected = !!source.connected;
const leds: LedState[] = isConnected ? ['on', 'on'] : ['fault'];
const healthTitle = isConnected
? `${t('ha_source.connected')}${source.entity_count} entities`
: t('ha_source.disconnected');
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteHASource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_HA} ${statusDot} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.host)}
</span>
${source.connected ? `<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg> ${source.entity_count} entities
</span>` : ''}
${source.use_ssl ? `<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg> SSL
</span>` : ''}
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('ha_source.test')}">${ICON_REFRESH}</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>`,
});
const chips: ModChipOpts[] = [
{ icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, text: source.host, title: source.host },
];
if (isConnected) {
chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`, text: `${source.entity_count} entities` });
}
if (source.use_ssl) {
chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`, text: 'SSL' });
}
const mod: ModCardOpts = {
head: {
badge: { text: 'HA · BRIDGE' },
name: source.name,
metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`),
leds,
menu: {
duplicateOnclick: `cloneHASource('${source.id}')`,
hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`,
deleteOnclick: `deleteHASource('${source.id}')`,
},
},
body: {
desc: source.description || undefined,
chips,
},
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 ──
@@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
import * as P from '../core/icon-paths.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 type { MQTTSource } from '../types.ts';
@@ -234,41 +235,44 @@ async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
// ── Card rendering ──
export function createMQTTSourceCard(source: MQTTSource) {
let healthClass: string, healthTitle: string;
if (source.connected) {
healthClass = 'health-online';
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>`;
const isConnected = !!source.connected;
const leds: LedState[] = isConnected ? ['on', 'blink'] : ['fault'];
const broker = `${source.broker_host}:${source.broker_port}`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteMQTTSource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_MQTT} ${statusDot} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.broker_host)}:${source.broker_port}
</span>
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.hash}</svg> ${escapeHtml(source.base_topic)}
</span>
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('mqtt_source.test')}">${ICON_TEST}</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>`,
});
const chips: ModChipOpts[] = [
{ icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, text: broker, title: broker },
{ icon: `<svg class="icon" viewBox="0 0 24 24">${P.hash}</svg>`, text: source.base_topic, title: source.base_topic },
];
const mod: ModCardOpts = {
head: {
badge: { text: 'MQTT · BROKER' },
name: source.name,
metaHtml: escapeHtml(`${broker} · ${source.base_topic}`),
leds,
menu: {
duplicateOnclick: `cloneMQTTSource('${source.id}')`,
hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`,
deleteOnclick: `deleteMQTTSource('${source.id}')`,
},
},
body: {
desc: source.description || undefined,
chips,
},
foot: {
patchState: isConnected ? 'live' : 'offline',
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 ──
@@ -16,17 +16,17 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts';
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
* patches / devices cells that don't have sparklines but still
* 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 SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
const SPARK_H = 64;
/** 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
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',
fps: '--ch-cyan',
capture_fps: '--ch-signal',
capture_fps_actual: '--ch-cyan',
errors: '--ch-coral',
devices: '--ch-signal',
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
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). */
@@ -48,19 +52,23 @@ const METRIC_FALLBACK: Record<string, string> = {
patches: '#EC4899',
fps: '#00D8FF',
capture_fps: '#22D3EE',
capture_fps_actual: '#00D8FF',
errors: '#FF6B6B',
devices: '#10B981',
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
network: '#A855F7',
device_latency: '#FCD34D',
send_timing: '#EC4899',
};
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] };
let _appHistory: 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: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] };
/** Peak errors-per-second observed during the session y-axis ceiling
* for the errors sparkline so a single spike doesn't flatten the rest
* of the line. */
@@ -77,6 +85,19 @@ let _prevSkippedTotal: number | null = null;
let _fpsPeak = 60;
/** Same role as `_fpsPeak`, but for the capture-side sparkline. */
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
* reference line on the FPS spark ("max achievable throughput"). */
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 _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 _lastTotalCaptureFpsActualArgs: { totalFps: number; targetSum: number; reportingCount: 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;
/** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy
* callers that read it directly; sync'd from the layout on every read
@@ -166,28 +191,24 @@ export function setPerfMode(mode: PerfMode): void {
_fetchPerformance();
}
/** Returns the static HTML for the perf section. */
export function renderPerfSection(): string {
_syncMode();
for (const key of ALL_COLORABLE_KEYS) {
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
}
/** 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({
/** 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. */
function _colorWidget(key: string): string {
return createColorPicker({
id: `perf-${key}`,
currentColor: _getColor(key),
onPick: undefined,
anchor: 'left',
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-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>
</div>
<div class="perf-chart-body">
@@ -198,11 +219,19 @@ export function renderPerfSection(): string {
<div class="perf-chart-spark" id="perf-chart-${key}"></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-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 class="perf-chart-body">
<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>
</div>`;
const fpsCell = `
case 'fps':
return `
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('fps')}">
<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 class="perf-chart-body">
<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>
</div>`;
const captureFpsCell = `
case 'capture_fps':
return `
<div class="perf-chart-card" data-metric="capture_fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps')}">
<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 class="perf-chart-body">
<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>
</div>`;
const errorsCell = `
case 'capture_fps_actual':
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-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 class="perf-chart-body">
<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>
</div>`;
const devicesCell = `
case 'network':
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-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 class="perf-chart-body">
<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>
</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
// 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.
const cellRenderers: Record<string, () => string> = {
patches: () => patchesCell,
fps: () => fpsCell,
capture_fps: () => captureFpsCell,
errors: () => errorsCell,
devices: () => devicesCell,
cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false),
ram: () => sparkCard('ram', 'dashboard.perf.ram', false),
gpu: () => sparkCard('gpu', 'dashboard.perf.gpu', false),
temp: () => sparkCard('temp', 'dashboard.perf.temp', true),
};
/** Re-register color-picker callbacks for every colorable cell. Idempotent
* overwrites the previous handler keyed by id. Called whenever the
* perf section is (re)initialised so newly-created cells get wired up. */
function _registerPerfColorPickers(): void {
for (const key of ALL_COLORABLE_KEYS) {
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
}
}
/** Build a fresh `.perf-chart-card` element from a key. */
function _buildCellElement(key: string): HTMLElement | null {
const html = _renderCellHtml(key);
if (!html) return null;
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 = '';
for (const cell of getOrderedPerfCells()) {
if (!cell.visible) continue;
const render = cellRenderers[cell.key];
if (render) cellsHtml += render();
const html = _renderCellHtml(cell.key);
if (html) cellsHtml += html;
}
return `<div class="perf-charts-grid">${cellsHtml}</div>`;
@@ -382,12 +481,12 @@ export function updateTotalFps(
_renderChartSvg('fps', /*animate=*/true);
}
/** Total Capture FPS cell pushed a new sample each dashboard refresh
* cycle. `totalFps` is the sum of `fps_capture` (configured capture-side
* rate) across running targets; `minFps` / `maxFps` are the live
* extremes shown as a subdued subtitle. Mirrors `updateTotalFps` but
* for the capture side, so multi-stream setups can see how much capture
* work is being scheduled. */
/** Total Source FPS cell sum of every running target's upstream
* color-strip-source `target_fps` (picture/audio/gradient/effect/...).
* This is the *requested* tick rate of the pipeline feeding LEDs, not
* the measured throughput of any external capture a static-color
* stream still ticks at its idle rate and contributes here. The
* internal key stays `capture_fps` for layout-storage compatibility. */
export function updateTotalCaptureFps(
totalFps: number,
minFps: number | null,
@@ -415,6 +514,184 @@ export function updateTotalCaptureFps(
_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
* `frames_skipped` totals (summed across running targets) into rates by
* 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
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 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 === '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;
const paths: string[] = [];
@@ -884,6 +1165,12 @@ async function _seedFromServer(): Promise<void> {
// the full /system/performance payload that does include totals.
_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) {
_hasGpu = true;
} 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
* spark hover tooltips. Also fires one immediate `_fetchPerformance` so
* 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.
*
* Replaces just the `.perf-charts-grid` element (cell count / order /
* mode / window / yScale all read from the layout via `renderPerfSection`),
* then replays the cached state into the new DOM:
* - sparkline SVGs from the persistent `_history` arrays
* - cpu/ram/gpu/temp value labels from `_lastFetchData`
* - patches/total-fps/devices cells from cached external setter args
* Reconciles the existing `.perf-charts-grid` against the desired cell
* set + order from the layout. Cells that already exist are kept in
* place (or moved to a new index) their DOM, listeners, color picker
* state, hidden-by-env state, and label values all survive intact. Only
* cells that were not visible before are freshly created; cells that
* disappeared from the layout are removed.
*
* This avoids the full-dashboard innerHTML wipe that previously caused a
* frame of layout flicker plus a window where every cell showed "0" /
* "—" until the next dashboard fetch landed. */
* Replay of cached state targets only newly-created cells, so unchanged
* cells don't get a phantom-scroll animation from a fresh
* `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 {
const wrapper = document.querySelector('.dashboard-perf-persistent');
if (!wrapper) return;
const oldGrid = wrapper.querySelector('.perf-charts-grid');
if (!oldGrid) return;
const grid = wrapper.querySelector<HTMLElement>('.perf-charts-grid');
if (!grid) return;
// `renderPerfSection()` returns the entire `.perf-charts-grid` div.
const tmp = document.createElement('div');
tmp.innerHTML = renderPerfSection();
const newGrid = tmp.firstElementChild;
if (!newGrid) return;
oldGrid.replaceWith(newGrid);
_syncMode();
_registerPerfColorPickers();
// 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);
// Re-apply env-detection visibility (the new HTML always renders
// gpu/temp cells without the hidden attr; cached `_hasGpu/_hasTemp`
// tell us what to actually do).
if (_hasGpu === false) {
// Re-apply env-detection visibility for newly-created gpu/temp cells
// (the HTML template renders them unhidden by default).
if (newKeys.has('gpu') && _hasGpu === false) {
const card = document.getElementById('perf-gpu-card');
if (card) card.setAttribute('hidden', '');
}
if (_hasTemp === true) {
if (newKeys.has('temp') && _hasTemp === true) {
const card = document.getElementById('perf-temp-card');
if (card) card.removeAttribute('hidden');
}
// Replay cached values so labels show real numbers, not "—".
if (_lastFetchData) {
// Replay cached state only into newly-created cells. Existing cells
// 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);
}
if (_lastPatchesArgs) {
if (newKeys.has('patches') && _lastPatchesArgs) {
updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount);
}
if (_lastTotalFpsArgs) {
if (newKeys.has('fps') && _lastTotalFpsArgs) {
updateTotalFps(
_lastTotalFpsArgs.totalFps,
_lastTotalFpsArgs.minFps,
@@ -977,14 +1525,42 @@ export function rerenderPerfGrid(): void {
_lastTotalFpsArgs.targetSum,
);
}
if (_lastTotalCaptureFpsArgs) {
if (newKeys.has('capture_fps') && _lastTotalCaptureFpsArgs) {
updateTotalCaptureFps(
_lastTotalCaptureFpsArgs.totalFps,
_lastTotalCaptureFpsArgs.minFps,
_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
// older baseline (e.g. layout-change re-render after a long
// session). Pin the baseline to the cached totals so the call
@@ -997,7 +1573,7 @@ export function rerenderPerfGrid(): void {
_lastErrorsArgs.pollMs,
);
}
if (_lastDevicesArgs) {
if (newKeys.has('devices') && _lastDevicesArgs) {
updateDevices(_lastDevicesArgs);
}
}
@@ -1026,7 +1602,13 @@ function _ensureTooltip(): HTMLDivElement {
/** Format a sampled value per metric for the tooltip line. */
function _formatSampleValue(key: string, v: number): string {
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`;
return `${v.toFixed(1)}%`;
}
@@ -1037,7 +1619,11 @@ function _metricLabel(key: string): string {
if (key === 'gpu') return 'GPU';
if (key === 'temp') return 'Temp';
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';
return key.toUpperCase();
}
@@ -9,11 +9,12 @@ import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
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';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.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 { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -81,35 +82,81 @@ export function createSceneCard(preset: ScenePreset) {
const automations = automationsCacheObj.data || [];
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
usedByCount > 0 ? `${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 colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">${ICON_TRASH}</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}"><span class="card-title-text">${escapeHtml(preset.name)}</span></div>
</div>
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''}
<div class="stream-card-props">
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
</div>
${renderTagChips(preset.tags)}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" data-action="clone-scene" data-id="${preset.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-scene" data-id="${preset.id}" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" data-action="recapture-scene" data-id="${preset.id}" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
${cardColorButton(preset.id, 'data-scene-id')}
</div>
</div>`;
// ── Badge: SCN · XX (last 2 hex chars of id, mirrors AUTO · 07 in
// automations.ts and SCN · 04 in cards-redesign-demo-v2). ──
const shortId = (preset.id || '').replace(/^scn_/i, '').slice(-2).toUpperCase() || 'NA';
// ── Meta line: target count + last-updated timestamp. The "used by
// N automations" hint moves down into a chip so it reads as a
// crosslink, not a count. ──
const metaParts: string[] = [];
if (targetCount > 0) metaParts.push(`${targetCount} ${t('scenes.targets_count')}`);
if (updated) metaParts.push(updated);
const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
// ── Chips: usage crosslink + target count quick-jump. ──
const chips: ModChipOpts[] = [];
if (usedByCount > 0) {
chips.push({
icon: ICON_LINK,
text: t('scene_preset.used_by').replace('%d', String(usedByCount)),
variant: 'tag',
});
}
// ── 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) =====
@@ -510,7 +557,7 @@ export function initScenePresetDelegation(container: HTMLElement): void {
if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button
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;
}
+340 -62
View File
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.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 { openAuthedWs } from '../core/ws-auth.ts';
import {
@@ -19,6 +19,7 @@ import {
// ─── External URL (used by other modules for user-visible URLs) ──
let _externalUrl = '';
let _externalUrlInputBound = false;
/** Get the configured external base URL (empty string = not set). */
export function getExternalUrl(): string {
@@ -33,6 +34,24 @@ export function getBaseOrigin(): string {
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> {
try {
const resp = await fetchWithAuth('/system/external-url');
@@ -41,6 +60,8 @@ export async function loadExternalUrl(): Promise<void> {
_externalUrl = data.external_url || '';
const input = document.getElementById('settings-external-url') as HTMLInputElement | null;
if (input) input.value = _externalUrl;
_bindExternalUrlInput();
_updateExternalUrlSaveBar();
} catch (err) {
console.error('Failed to load external URL:', err);
}
@@ -62,6 +83,7 @@ export async function saveExternalUrl(): Promise<void> {
const data = await resp.json();
_externalUrl = data.external_url || '';
input.value = _externalUrl;
_updateExternalUrlSaveBar();
showToast(t('settings.external_url.saved'), 'success');
} catch (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 ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void {
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;
btn.classList.toggle('active', isActive);
if (isActive) activeBtn = btn as HTMLElement;
@@ -83,10 +115,18 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
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) {
(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.
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
// Lazy-render the appearance tab content
@@ -113,6 +153,13 @@ export function switchSettingsTab(tabId: string): void {
/** @type {WebSocket|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 */
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);
}
/** 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 {
// Skip keepalive empty pings
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;
const output = document.getElementById('log-viewer-output');
if (!output) return;
const level = _detectLevel(line);
const cls = _levelClass(level);
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = line + '\n';
@@ -163,22 +294,22 @@ function _appendLine(line: string): void {
}
export function connectLogViewer(): void {
const btn = document.getElementById('log-viewer-connect-btn');
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
// Disconnect
_logWs.close();
_logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
_setLogConnectionState('idle');
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/v1/system/logs/ws`;
_setLogConnectionState('connecting');
openAuthedWs(url).then((ws) => {
_logWs = ws;
if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; }
_setLogConnectionState('live');
ws.onmessage = (evt) => {
_appendLine(evt.data);
@@ -186,14 +317,16 @@ export function connectLogViewer(): void {
ws.onerror = () => {
showToast(t('settings.logs.error'), 'error');
_setLogConnectionState('error');
};
ws.onclose = () => {
_logWs = null;
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
_setLogConnectionState('idle');
};
}).catch(() => {
showToast(t('settings.logs.error'), 'error');
_setLogConnectionState('error');
});
}
@@ -202,13 +335,14 @@ export function disconnectLogViewer(): void {
_logWs.close();
_logWs = null;
}
const btn = document.getElementById('log-viewer-connect-btn');
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
_setLogConnectionState('idle');
}
export function clearLogViewer(): void {
const output = document.getElementById('log-viewer-output');
if (output) output.innerHTML = '';
_resetLogStats();
document.getElementById('log-overlay')?.classList.remove('has-data');
}
/** 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 _shutdownActionIconSelect: IconSelect | null = null;
// Notification matrix: one IconSelect per event type. Constructed lazily
// when the Notifications tab is first opened so the icon palette and i18n
// strings have a chance to load.
const _notifIconSelects: Partial<Record<keyof NotificationPreferences['channels'], IconSelect>> = {};
// Notifications: the visual matrix is now the source of truth — see
// initNotificationsPanel() / _setNotifMatrixSelection() below.
type ShutdownAction = 'stop_targets' | 'nothing';
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) {
const sel = document.getElementById('auto-backup-interval') as HTMLSelectElement | null;
if (sel) {
@@ -367,6 +500,7 @@ export function openSettingsModal(): void {
target: sel,
items: _getHourIntervalItems(),
columns: 3,
onChange: () => saveAutoBackupSettings(),
});
}
}
@@ -387,9 +521,26 @@ export function openSettingsModal(): void {
loadApiKeysList();
loadExternalUrl();
loadAutoBackupSettings();
_bindAutoBackupListeners();
loadBackupList();
loadLogLevel();
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 {
@@ -500,15 +651,36 @@ export async function loadAutoBackupSettings(): Promise<void> {
} else {
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) {
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> {
const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked;
const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value);
const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).value, 10);
const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null;
const intervalEl = document.getElementById('auto-backup-interval') as HTMLInputElement | null;
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 {
const resp = await fetchWithAuth('/system/auto-backup/settings', {
@@ -519,7 +691,6 @@ export async function saveAutoBackupSettings(): Promise<void> {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.auto_backup.saved'), 'success');
loadAutoBackupSettings();
loadBackupList();
} 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> {
try {
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> {
const container = document.getElementById('saved-backups-list')!;
const meta = document.getElementById('saved-backups-meta');
container.setAttribute('data-empty', t('settings.saved_backups.empty'));
try {
const resp = await fetchWithAuth('/system/backups');
if (!resp.ok) return;
const data = await resp.json();
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;
}
let totalBytes = 0;
container.innerHTML = data.backups.map(b => {
const sizeBytes = b.size_bytes || 0;
totalBytes += sizeBytes;
const sizeStr = sizeBytes >= 1024 * 1024
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
: (sizeBytes / 1024).toFixed(1) + ' KB';
const date = new Date(b.created_at).toLocaleString();
const isAuto = b.filename.startsWith('ledgrab-autobackup-');
const typeBadge = isAuto
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>`
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`;
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
${typeBadge}
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
<span>${date}</span>
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeStr}</span>
const typeKey = isAuto ? 'settings.saved_backups.type.auto' : 'settings.saved_backups.type.manual';
return `<div class="backup-row">
<div>
<div class="backup-name" title="${b.filename}">${date}</div>
<div class="backup-meta">${sizeStr} · ${t(typeKey)}</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="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="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" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}">${ICON_UNDO}</button>
<button class="icon-btn" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}">${ICON_DOWNLOAD}</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>`;
}).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) {
console.error('Failed to load backup list:', err);
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> {
const container = document.getElementById('settings-api-keys-list');
if (!container) return;
const meta = document.getElementById('settings-api-keys-meta');
try {
const resp = await fetchWithAuth('/system/api-keys');
if (!resp.ok) {
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`;
container.innerHTML = `<div class="api-key-empty-note">${t('settings.api_keys.load_error')}</div>`;
if (meta) meta.hidden = true;
return;
}
const data = await resp.json();
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;
}
container.innerHTML = data.keys.map(k =>
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);">
<span style="font-weight:600;min-width:80px;">${k.label}</span>
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code>
`<div class="api-key-row">
<span class="api-key-name">${k.label}</span>
<span class="api-key-mask">${k.masked}</span>
<span class="api-key-tag">${t('settings.api_keys.read_only')}</span>
</div>`
).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) {
console.error('Failed to load API keys:', err);
if (container) container.innerHTML = '';
if (meta) meta.hidden = true;
}
}
@@ -776,33 +982,88 @@ const _NOTIF_EVENT_KEYS = [
] as const;
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 {
return v === 'none' || v === 'snack' || v === 'os' || v === 'both';
}
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> {
// Build IconSelects (idempotent).
for (const key of _NOTIF_EVENT_KEYS) {
if (_notifIconSelects[key]) continue;
const sel = document.getElementById(`settings-notif-${key.replace(/_/g, '-')}`) as HTMLSelectElement | null;
if (!sel) continue;
_notifIconSelects[key] = new IconSelect({
target: sel,
items: _getNotifChannelItems(),
columns: 2,
onChange: () => saveNotifPreferencesFromUi(),
});
// Wire matrix cells (idempotent).
if (!_notifMatrixWired) {
const matrix = document.getElementById('settings-notif-matrix');
if (matrix) {
// Pre-populate hidden selects with all channel options so
// saveNotifPreferencesFromUi() can read .value back without
// depending on IconSelect.
for (const event of _NOTIF_EVENT_KEYS) {
const sel = document.getElementById(
`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;
@@ -815,7 +1076,7 @@ export async function initNotificationsPanel(): Promise<void> {
try {
const prefs = await refreshNotificationPreferences();
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;
_notifPrefsLoaded = true;
@@ -829,18 +1090,35 @@ export async function initNotificationsPanel(): Promise<void> {
function _refreshNotifPermissionState(): void {
const stateEl = document.getElementById('settings-notif-permission-state');
if (!stateEl) return;
const perm = getOsPermission();
const key = perm === 'granted'
? 'settings.notifications.permission.state.granted'
: perm === 'denied'
? 'settings.notifications.permission.state.denied'
: '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(
'#settings-notif-permission-row button',
'#settings-notif-permission-row .btn',
) as HTMLButtonElement | null;
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> {
+380 -228
View File
@@ -67,6 +67,7 @@ import {
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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 { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -344,50 +345,92 @@ const _streamSectionMap = {
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) => {
let capTmplName = '';
const chips: ModChipOpts[] = [];
if (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">
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${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>`;
const metaParts = [
`${t('streams.display')} ${stream.display_index ?? 0}`,
`${stream.target_fps ?? 30} fps`,
];
return {
badgeText: 'SCREEN · IN',
metaHtml: metaParts.map(escapeHtml).join(' · '),
chips,
};
},
processed: (stream) => {
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 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) {
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">
<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>
${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>` : ''}
</div>`;
return {
badgeText: 'PIC · OUT',
metaHtml: chips.length > 1 ? `${chips.length} ${escapeHtml(t('streams.pp_template') || 'filters')}` : escapeHtml(t('streams.source') || 'Source'),
chips,
};
},
static_image: (stream) => {
const assetName = _getAssetName(stream.image_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
</div>`;
return {
badgeText: 'IMG · IN',
metaHtml: escapeHtml(assetName),
chips: [{ icon: ICON_ASSET, text: assetName, title: assetName }],
};
},
video: (stream) => {
const assetName = _getAssetName(stream.video_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${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>` : ''}
</div>`;
const chips: ModChipOpts[] = [
{ icon: ICON_ASSET, text: assetName, title: assetName },
{ icon: ICON_FPS, text: `${stream.target_fps ?? 30} fps`, title: t('streams.target_fps') },
];
if (stream.loop !== false) chips.push({ text: '↻ loop' });
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 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 detailsHtml = renderer ? renderer(stream) : '';
const sectionKey = stream.stream_type === 'static_image' ? 'static-streams'
: stream.stream_type === 'video' ? 'video-streams'
: stream.stream_type === 'processed' ? 'proc-streams'
: 'raw-streams';
return wrapCard({
type: 'template-card',
dataAttr: 'data-stream-id',
id: stream.id,
removeOnclick: `deleteStream('${stream.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(stream.name)}">${typeIcon} ${escapeHtml(stream.name)}</div>
</div>
${detailsHtml}
${renderTagChips(stream.tags)}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
<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>`,
});
const mod: ModCardOpts = {
head: {
badge: { text: details.badgeText },
name: stream.name,
metaHtml: details.metaHtml,
leds: ['off'],
menu: {
duplicateOnclick: `cloneStream('${stream.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`,
deleteOnclick: `deleteStream('${stream.id}')`,
},
},
body: {
desc: stream.description || undefined,
chips: details.chips.length ? details.chips : undefined,
},
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 engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return wrapCard({
type: 'template-card',
dataAttr: 'data-template-id',
id: template.id,
removeOnclick: `deleteTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
<div class="template-config-collapse">
<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>
const configEntries = Object.entries(template.engine_config || {});
const chips: ModChipOpts[] = [
{ icon: getEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('templates.engine') },
];
if (configEntries.length > 0) {
chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('templates.config.show') || 'config')}`, title: t('templates.config.show') });
}
const configBlock = configEntries.length > 0 ? `
<div class="template-config-collapse">
<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>
` : ''}`,
actions: `
<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>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
</div>
</div>` : '';
const mod: ModCardOpts = {
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) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getFilterName(fi.filter_id);
if (fi.filter_id === 'filter_template' && fi.options?.template_id) {
const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">→</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL · FILTER' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
leds: ['off'],
menu: {
duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
deleteOnclick: `deletePPTemplate('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
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) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getStripFilterName(fi.filter_id);
if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) {
const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
});
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL \u00b7 STRIP' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
leds: ['off'],
menu: {
duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
deleteOnclick: `deleteCSPT('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
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');
@@ -646,120 +740,178 @@ function renderPictureSourcesList(streams: 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') {
badgeText = 'AUDIO · FX';
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 parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture';
const parentBadge = parent
? `<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>`
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
propsHtml = `${parentBadge}`;
chips.push({
icon: parent ? getAudioSourceIcon(parent.source_type) : ICON_AUDIO_LOOPBACK,
text: parentName,
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) {
const aptTmpl = _cachedAudioProcessingTemplates.find(t => t.id === src.audio_processing_template_id);
const aptName = aptTmpl ? escapeHtml(aptTmpl.name) : escapeHtml(src.audio_processing_template_id);
propsHtml += aptTmpl
? `<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>`
: `<span class="stream-card-prop">${ICON_AUDIO_TEMPLATE} ${aptName}</span>`;
const aptTmpl = _cachedAudioProcessingTemplates.find(tt => tt.id === src.audio_processing_template_id);
const aptName = aptTmpl ? aptTmpl.name : src.audio_processing_template_id;
chips.push({
icon: ICON_AUDIO_TEMPLATE,
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 {
// Capture source
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
const devIdx = src.device_index ?? -1;
badgeText = loopback ? 'LOOP · IN' : 'MIC · IN';
const devLabel = loopback ? 'Loopback' : 'Input';
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({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(src.name)}">${icon} ${escapeHtml(src.name)}</div>
</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone-audio" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-audio" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
const sectionKey = src.source_type === 'processed' ? 'audio-processed' : 'audio-capture';
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: src.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
menu: {
duplicateOnclick: `cloneAudioSource('${src.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
deleteOnclick: `deleteAudioSource('${src.id}')`,
},
},
body: {
desc: src.description || undefined,
chips: chips.length ? chips : undefined,
},
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 configEntries = Object.entries(template.engine_config || {});
return wrapCard({
type: 'template-card',
dataAttr: 'data-audio-template-id',
id: template.id,
removeOnclick: `deleteAudioTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.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>
const chips: ModChipOpts[] = [
{ icon: getAudioEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('audio_template.engine') },
];
if (configEntries.length > 0) {
chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('audio_template.config.show') || 'config')}`, title: t('audio_template.config.show') });
}
const configBlock = configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.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>
` : ''}`,
actions: `
<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>
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
</div>
</div>` : '';
const mod: ModCardOpts = {
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
const renderGradientCard = (g: GradientEntity) => {
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>`;
const lockBadge = g.is_builtin ? `<span class="badge badge-info" style="font-size:0.7em;margin-left:4px">${t('gradient.builtin')}</span>` : '';
const cloneBtn = `<button class="btn btn-icon btn-secondary" onclick="cloneGradient('${g.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>`;
const editBtn = g.is_builtin ? '' : `<button class="btn btn-icon btn-secondary" onclick="editGradient('${g.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: g.id,
removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}</div>
</div>
${stripPreview}
<div class="stream-card-props">
<span class="stream-card-prop">${g.stops.length} ${t('gradient.stops_label')}</span>
</div>`,
actions: `${cloneBtn}${editBtn}`,
});
// The `.mod-preview` wrapper inside renderModBody doesn't accept
// inline style, so emit a sibling block via `extraHtml` so the
// gradient fills the full preview surface.
const previewBlock = `<div class="mod-preview mod-preview--strip" style="height:36px;background:linear-gradient(to right,${cssStops});">${
g.is_builtin ? `<span class="mod-preview__tag">${escapeHtml(t('gradient.builtin') || 'BUILTIN').toUpperCase()}</span>` : ''
}</div>`;
const iconActions: any[] = [
{ icon: ICON_CLONE, onclick: `cloneGradient('${g.id}')`, title: t('common.clone') },
];
if (!g.is_builtin) {
iconActions.push({ icon: ICON_EDIT, onclick: `editGradient('${g.id}')`, title: t('common.edit') });
}
const mod: ModCardOpts = {
head: {
badge: { text: 'PALETTE · GRD' },
name: g.name,
metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
leds: ['off'],
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
@@ -9,6 +9,7 @@ import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.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 { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.ts';
@@ -223,35 +224,51 @@ function _formatElapsed(seconds: number): string {
}
export function createSyncClockCard(clock: SyncClock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running ? 'pause' : 'resume';
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const isRunning = !!clock.is_running;
const statusLabel = isRunning ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = isRunning ? 'pause' : '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;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: clock.id,
removeOnclick: `deleteSyncClock('${clock.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CLOCK} ${escapeHtml(clock.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
${elapsedLabel ? `<span class="stream-card-prop" title="${t('sync_clock.elapsed')}">⏱ ${elapsedLabel}</span>` : ''}
</div>
${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<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>`,
});
const chips: ModChipOpts[] = [
{ icon: ICON_CLOCK, text: `${clock.speed}x` },
];
if (elapsedLabel) {
chips.push({ text: `${elapsedLabel}`, title: t('sync_clock.elapsed') });
}
const leds: LedState[] = isRunning ? ['on', 'blink'] : ['off'];
const mod: ModCardOpts = {
head: {
badge: { text: 'CLK · MASTER' },
name: clock.name,
metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`),
leds,
menu: {
duplicateOnclick: `cloneSyncClock('${clock.id}')`,
hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`,
deleteOnclick: `deleteSyncClock('${clock.id}')`,
},
},
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 ──
+534 -10
View File
@@ -10,6 +10,12 @@ import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
// ─── State ──────────────────────────────────────────────────
interface UpdateAsset {
name: string;
size: number;
download_url: string;
}
interface UpdateRelease {
version: string;
tag: string;
@@ -17,6 +23,7 @@ interface UpdateRelease {
body: string;
prerelease: boolean;
published_at: string;
assets?: UpdateAsset[];
}
interface UpdateStatus {
@@ -179,7 +186,15 @@ function _applyStatus(status: UpdateStatus): void {
&& status.release != null
&& 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);
_setRailUpdateBadge(hasAnyUpdate, status.release?.version ?? '');
if (hasVisibleUpdate) {
_showBanner(status);
@@ -190,6 +205,24 @@ function _applyStatus(status: UpdateStatus): void {
_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 ─────────────────────────────────────
export function initUpdateListener(): void {
@@ -282,6 +315,8 @@ function _getChannelItems(): { value: string; icon: string; label: string; desc:
}
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) {
const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
if (sel) {
@@ -289,6 +324,7 @@ export function initUpdateSettingsPanel(): void {
target: sel,
items: _getChannelItems(),
columns: 2,
onChange: () => saveUpdateSettings(),
});
}
}
@@ -299,9 +335,16 @@ export function initUpdateSettingsPanel(): void {
target: sel,
items: _getIntervalItems(),
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> {
@@ -331,12 +374,17 @@ export async function loadUpdateSettings(): Promise<void> {
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> {
const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
const check_interval_hours = parseFloat(intervalStr);
const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
const include_prerelease = channelVal === 'true';
if (Number.isNaN(check_interval_hours)) return;
try {
const resp = await fetchWithAuth('/system/update/settings', {
@@ -347,7 +395,6 @@ export async function saveUpdateSettings(): Promise<void> {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('update.settings_saved'), 'success');
} catch (err) {
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}`;
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 (status.has_update && status.release) {
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) {
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 {
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 ─────────────────────────────────
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 {
const overlay = document.getElementById('release-notes-overlay');
const content = document.getElementById('release-notes-content');
if (overlay && content) {
import('marked').then(({ marked }) => {
content.innerHTML = marked.parse(_releaseNotesBody) as string;
overlay.style.display = 'flex';
});
}
if (!overlay || !content) return;
_renderReleaseNotesHeader();
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 {
@@ -27,6 +27,7 @@ import {
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
} from '../core/icons.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 { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
@@ -1228,46 +1229,63 @@ function _renderVsColorSwatch() {
// ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src: ValueSource) {
const icon = getValueSourceIcon(src.source_type);
const VALUE_BADGE: Record<string, string> = {
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') {
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') {
const waveLabel = src.waveform || 'sine';
propsHtml = `
<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
chips.push({ icon: ICON_ACTIVITY, text: waveLabel });
chips.push({ icon: ICON_TIMER, text: `${src.speed ?? 10} cpm` });
chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
metaText = `${waveLabel} · ${src.speed ?? 10} cpm`;
} else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === 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 audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
const modeLabel = src.mode || 'rms';
const audioBadge = audioSrc
? `<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>`
: `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`;
propsHtml = `
${audioBadge}
<span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
chips.push({
icon: ICON_MUSIC, text: audioName, title: t('value_source.audio_source'),
onclick: audioSrc ? `event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')` : undefined,
});
chips.push({ icon: ICON_TRENDING_UP, text: modeLabel.toUpperCase() });
chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
metaText = `${audioName} · ${modeLabel.toUpperCase()}`;
} else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length;
propsHtml = `
<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}${src.max_value ?? 1}` });
metaText = `${pts} schedule pts`;
} else if (src.source_type === 'daylight') {
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 {
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') {
const ps = _cachedStreams.find(s => s.id === 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'; }
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
}
const psBadge = ps
? `<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>`
: `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`;
propsHtml = `
${psBadge}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`;
chips.push({
icon: ICON_MONITOR, text: psName, title: t('value_source.picture_source'),
onclick: ps ? `event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')` : undefined,
});
chips.push({ icon: ICON_REFRESH, text: src.scene_behavior || 'complement' });
metaText = `${psName} · ${src.scene_behavior || 'complement'}`;
} else if (src.source_type === 'static_color') {
const rgb = (src as any).color || [255, 255, 255];
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') {
const colors = (src as any).colors || [];
propsHtml = `
<span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span>
<span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span>
`;
chips.push({ icon: ICON_ACTIVITY, text: `${colors.length} ${t('value_source.animated_color.color_count') || 'colors'}` });
chips.push({ icon: ICON_TIMER, text: `${(src as any).speed ?? 10} cpm` });
metaText = `${colors.length} colors`;
} else if (src.source_type === 'adaptive_time_color') {
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') {
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 entityId = (src as any).entity_id || '';
const attr = (src as any).attribute;
const haBadge = haSrc
? `<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>`
: `<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>`;
propsHtml = `
${haBadge}
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span>
`;
chips.push({
icon: ICON_HOME, text: haName, title: t('value_source.ha_source'),
onclick: haSrc ? `event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')` : undefined,
});
chips.push({ icon: ICON_LINK, text: `${entityId}${attr ? '.' + attr : ''}` });
chips.push({ icon: ICON_MOVE_VERTICAL, text: `${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}` });
metaText = `${haName} \u00b7 ${entityId}`;
} else if (src.source_type === 'gradient_map') {
const inputVs = _cachedValueSources.find(v => v.id === (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
? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
: '#333';
const inputBadge = inputVs
? `<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>`
: `<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>`;
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>`
: `<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>`;
propsHtml = `
${inputBadge}
${gradBadge}
<div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div>
`;
chips.push({
icon: ICON_LINK, text: inputName, title: t('value_source.gradient_map.input'),
onclick: inputVs ? `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${(src as any).value_source_id}')` : undefined,
});
chips.push({
icon: ICON_RAINBOW, text: gradName, title: t('value_source.gradient_map.gradient'),
onclick: grad ? `event.stopPropagation(); navigateToCard('streams','gradients','gradients','data-id','${(src as any).gradient_id}')` : undefined,
});
extra = `<div class="mod-preview mod-preview--strip" style="height:8px;background:${gradientCss};"></div>`;
metaText = `${inputName} \u2192 ${gradName}`;
} else if (src.source_type === 'css_extract') {
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 ledStart = (src as any).led_start ?? 0;
const ledEnd = (src as any).led_end ?? -1;
const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`;
const cssBadge = cssSrc
? `<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>`
: `<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>`;
propsHtml = `
${cssBadge}
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
`;
chips.push({
icon: ICON_DROPLETS, text: cssName, title: t('value_source.css_extract.source'),
onclick: cssSrc ? `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${(src as any).color_strip_source_id}')` : undefined,
});
chips.push({ icon: ICON_MOVE_VERTICAL, text: `LED ${rangeLabel}` });
metaText = `${cssName} \u00b7 ${rangeLabel}`;
} else if (src.source_type === 'system_metrics') {
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({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteValueSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
return { chips, metaText, extra };
}
export function createValueSourceCard(src: ValueSource) {
const { chips, metaText, extra } = _valueSourceChipsAndExtras(src);
const badgeText = VALUE_BADGE[src.source_type] || 'VALUE \u00b7 IN';
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: src.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
menu: {
duplicateOnclick: `cloneValueSource('${src.id}')`,
hideOnclick: `toggleCardHidden('value-sources','${src.id}')`,
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 ───────────────────────────────────────────────────
@@ -11,6 +11,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.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 type { WeatherSource } from '../types.ts';
@@ -267,33 +268,42 @@ export function weatherSourceGeolocate(): void {
export function createWeatherSourceCard(source: WeatherSource) {
const intervalMin = Math.round(source.update_interval / 60);
const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider;
const coords = `${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteWeatherSource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_WEATHER} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_WEATHER} ${providerLabel}</span>
<span class="stream-card-prop" title="${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}">
<svg class="icon" viewBox="0 0 24 24">${P.mapPin}</svg> ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}
</span>
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.clock}</svg> ${intervalMin}min
</span>
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('weather_source.test')}">${ICON_TEST}</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>`,
});
const chips: ModChipOpts[] = [
{ icon: ICON_WEATHER, text: providerLabel },
{ icon: _icon(P.mapPin), text: coords, title: `${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}` },
{ icon: _icon(P.clock), text: `${intervalMin} min` },
];
const mod: ModCardOpts = {
head: {
badge: { text: 'WEATHER · IN' },
name: source.name,
metaHtml: escapeHtml(`${providerLabel} · ${coords}`),
leds: ['on'],
menu: {
duplicateOnclick: `cloneWeatherSource('${source.id}')`,
hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`,
deleteOnclick: `deleteWeatherSource('${source.id}')`,
},
},
body: {
desc: source.description || undefined,
chips,
},
foot: {
patchState: 'live',
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 ──
+1
View File
@@ -411,6 +411,7 @@ startTargetOverlay: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any;
revertExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any;
// ─── Appearance ───
+71 -6
View File
@@ -373,6 +373,10 @@
"settings.external_url.saved": "External URL saved",
"settings.external_url.save_error": "Failed to save external URL",
"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.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings",
@@ -804,7 +808,11 @@
"dashboard.perf.patches.empty.idle": "Ready to launch",
"dashboard.perf.patches.empty.none": "No patches yet",
"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.devices": "Devices",
"dashboard.perf.cpu": "CPU",
@@ -983,6 +991,9 @@
"scenes.capture": "Capture",
"scenes.activate": "Activate scene",
"scenes.recapture": "Recapture current state",
"scenes.action.activate": "Activate",
"scenes.action.recapture": "Recapture",
"scenes.status.preset": "Preset",
"scenes.delete": "Delete scene",
"scenes.targets_count": "targets",
"scenes.captured": "Scene captured",
@@ -1001,9 +1012,6 @@
"scenes.error.delete_failed": "Failed to delete scene",
"scenes.cloned": "Scene cloned",
"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.kc": "Key Colors",
"aria.close": "Close",
@@ -1771,12 +1779,12 @@
"search.action.disable": "Disable",
"settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
"settings.backup.button": "Download Backup",
"settings.backup.button": "Download",
"settings.backup.success": "Backup downloaded successfully",
"settings.backup.error": "Backup download failed",
"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.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.success": "Configuration restored",
"settings.restore.error": "Restore failed",
@@ -1855,6 +1863,15 @@
"settings.logs.filter.info_desc": "Info, warning, and errors",
"settings.logs.filter.warning_desc": "Warnings and 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.remove_failed": "Failed to remove device",
"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.denied": "Denied — change in browser settings",
"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.saved": "Notification preferences saved",
"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.both.label": "Both",
"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.device_online.title": "Device online",
"notifications.device_online.body": "{device} is back online",
@@ -2294,6 +2339,26 @@
"update.never": "never",
"update.release_notes": "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_hint": "Periodically check for new releases in the background.",
"update.enable": "Enable auto-check",
+71 -6
View File
@@ -377,6 +377,10 @@
"settings.external_url.saved": "Внешний URL сохранён",
"settings.external_url.save_error": "Не удалось сохранить внешний URL",
"settings.general.title": "Основные Настройки",
"settings.section.identity": "Идентификация",
"settings.section.connection": "Подключение",
"settings.section.hardware": "Оборудование",
"settings.section.behavior": "Поведение",
"settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата",
@@ -785,7 +789,11 @@
"dashboard.perf.patches.empty.idle": "Готов к запуску",
"dashboard.perf.patches.empty.none": "Каналов пока нет",
"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.devices": "Устройства",
"dashboard.perf.cpu": "ЦП",
@@ -964,6 +972,9 @@
"scenes.capture": "Захват",
"scenes.activate": "Активировать сцену",
"scenes.recapture": "Перезахватить текущее состояние",
"scenes.action.activate": "Активировать",
"scenes.action.recapture": "Перезахват",
"scenes.status.preset": "Пресет",
"scenes.delete": "Удалить сцену",
"scenes.targets_count": "целей",
"scenes.captured": "Сцена захвачена",
@@ -982,9 +993,6 @@
"scenes.error.delete_failed": "Не удалось удалить сцену",
"scenes.cloned": "Сцена клонирована",
"scenes.error.clone_failed": "Не удалось клонировать сцену",
"time.hours_minutes": "{h}ч {m}м",
"time.minutes_seconds": "{m}м {s}с",
"time.seconds": "{s}с",
"dashboard.type.led": "LED",
"dashboard.type.kc": "Цвета клавиш",
"aria.close": "Закрыть",
@@ -1587,12 +1595,12 @@
"search.action.disable": "Отключить",
"settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию",
"settings.backup.button": "Скачать",
"settings.backup.success": "Резервная копия скачана",
"settings.backup.error": "Ошибка скачивания резервной копии",
"settings.restore.label": "Восстановление конфигурации",
"settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.",
"settings.restore.button": "Восстановить из копии",
"settings.restore.button": "Восстановить",
"settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?",
"settings.restore.success": "Конфигурация восстановлена",
"settings.restore.error": "Ошибка восстановления",
@@ -1671,6 +1679,15 @@
"settings.logs.filter.info_desc": "Info, предупреждения и ошибки",
"settings.logs.filter.warning_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.remove_failed": "Не удалось удалить устройство",
"device.error.settings_load_failed": "Не удалось загрузить настройки устройства",
@@ -1972,6 +1989,7 @@
"settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС",
"settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера",
"settings.notifications.permission.state.default": "Разрешение ещё не запрошено",
"settings.notifications.permission.hint": "Браузер управляет разрешением на системные уведомления для каждого сайта отдельно. После отказа LedGrab уже не может запросить разрешение снова — его нужно сбросить в браузере. Нажмите на значок сайта (замок) в адресной строке → Настройки сайта → Уведомления → Разрешить, затем перезагрузите страницу.",
"settings.notifications.test_button": "Отправить тестовое уведомление",
"settings.notifications.saved": "Настройки уведомлений сохранены",
"settings.notifications.save_error": "Не удалось сохранить настройки уведомлений",
@@ -1983,6 +2001,33 @@
"settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)",
"settings.notifications.channel.both.label": "Оба",
"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.device_online.title": "Устройство в сети",
"notifications.device_online.body": "{device} снова в сети",
@@ -2010,6 +2055,26 @@
"update.never": "никогда",
"update.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_hint": "Периодически проверять наличие новых версий в фоновом режиме.",
"update.enable": "Включить автопроверку",
+71 -6
View File
@@ -377,6 +377,10 @@
"settings.external_url.saved": "外部 URL 已保存",
"settings.external_url.save_error": "保存外部 URL 失败",
"settings.general.title": "常规设置",
"settings.section.identity": "标识",
"settings.section.connection": "连接",
"settings.section.hardware": "硬件",
"settings.section.behavior": "行为",
"settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新",
"settings.capture.failed": "保存采集设置失败",
@@ -785,7 +789,11 @@
"dashboard.perf.patches.empty.idle": "准备就绪",
"dashboard.perf.patches.empty.none": "暂无通道",
"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.devices": "设备",
"dashboard.perf.cpu": "CPU",
@@ -964,6 +972,9 @@
"scenes.capture": "捕获",
"scenes.activate": "激活场景",
"scenes.recapture": "重新捕获当前状态",
"scenes.action.activate": "激活",
"scenes.action.recapture": "重新捕获",
"scenes.status.preset": "预设",
"scenes.delete": "删除场景",
"scenes.targets_count": "目标",
"scenes.captured": "场景已捕获",
@@ -982,9 +993,6 @@
"scenes.error.delete_failed": "删除场景失败",
"scenes.cloned": "场景已克隆",
"scenes.error.clone_failed": "克隆场景失败",
"time.hours_minutes": "{h}时 {m}分",
"time.minutes_seconds": "{m}分 {s}秒",
"time.seconds": "{s}秒",
"dashboard.type.led": "LED",
"dashboard.type.kc": "关键颜色",
"aria.close": "关闭",
@@ -1587,12 +1595,12 @@
"search.action.disable": "禁用",
"settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份",
"settings.backup.button": "下载",
"settings.backup.success": "备份下载成功",
"settings.backup.error": "备份下载失败",
"settings.restore.label": "恢复配置",
"settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。",
"settings.restore.button": "从备份恢复",
"settings.restore.button": "恢复",
"settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?",
"settings.restore.success": "配置已恢复",
"settings.restore.error": "恢复失败",
@@ -1671,6 +1679,15 @@
"settings.logs.filter.info_desc": "Info、警告和错误",
"settings.logs.filter.warning_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.remove_failed": "移除设备失败",
"device.error.settings_load_failed": "加载设备设置失败",
@@ -1970,6 +1987,7 @@
"settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示",
"settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改",
"settings.notifications.permission.state.default": "尚未请求授权",
"settings.notifications.permission.hint": "浏览器为每个站点单独管理系统通知权限。一旦被拒绝,LedGrab 将无法再次请求 — 必须在浏览器中重置。点击地址栏中的站点图标(锁形)→ 站点设置 → 通知 → 允许,然后刷新页面。",
"settings.notifications.test_button": "发送测试通知",
"settings.notifications.saved": "通知偏好已保存",
"settings.notifications.save_error": "保存通知偏好失败",
@@ -1981,6 +1999,33 @@
"settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)",
"settings.notifications.channel.both.label": "两者",
"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.device_online.title": "设备已上线",
"notifications.device_online.body": "{device} 已重新上线",
@@ -2008,6 +2053,26 @@
"update.never": "从未",
"update.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_hint": "在后台定期检查新版本。",
"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 class="modal-content">
<div class="modal-header">
@@ -9,219 +12,264 @@
<form id="device-settings-form">
<input type="hidden" id="settings-device-id">
<div class="form-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>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<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">
<div class="label-row">
<label for="settings-device-url" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · CONNECTION ─────────────────────────────── -->
<section class="ds-section" data-ds-key="connection" 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.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="settings-serial-port-group" style="display: none;">
<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="ds-section-body">
<div class="form-group" id="settings-url-group">
<div class="label-row">
<label for="settings-device-url" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="settings-serial-port-group" style="display: none;">
<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="label-row">
<label data-i18n="device.openrgb.zone">Zones:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<div id="settings-zone-list" class="zone-checkbox-list"></div>
</div>
<div class="form-group" id="settings-zone-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.openrgb.mode">Zone mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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="zone-mode-radios">
<label class="zone-mode-option">
<input type="radio" name="settings-zone-mode" value="combined" checked>
<span data-i18n="device.openrgb.mode.combined">Combined strip</span>
</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-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-ble-family-group" style="display: none;">
<div class="label-row">
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<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>
<div class="form-group" id="settings-led-type-group" style="display: none;">
<div class="label-row">
<label for="settings-led-type" data-i18n="device.led_type">LED Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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-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-zone-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.openrgb.zone">Zones:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<div id="settings-zone-list" class="zone-checkbox-list"></div>
</div>
<div class="form-group" id="settings-dmx-protocol-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<select id="settings-dmx-protocol">
<option value="artnet">Art-Net</option>
<option value="sacn">sACN (E1.31)</option>
</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>
<div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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 id="settings-group-children-list" class="group-children-list"></div>
<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>
<!-- BLE LED Controller fields -->
<div class="form-group" id="settings-ble-family-group" style="display: none;">
<div class="label-row">
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group" id="settings-ws-url-group" style="display: none;">
<div class="label-row">
<label for="settings-ws-url" data-i18n="device.ws_url">Connection URL:</label>
<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>
<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>
<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>
</section>
<!-- Group device fields -->
<div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 03 · HARDWARE ───────────────────────────────── -->
<section class="ds-section" data-ds-key="hardware" 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.hardware">Hardware</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</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 id="settings-group-children-list" class="group-children-list"></div>
<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>
<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="ds-section-body">
<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-led-type-group" style="display: none;">
<div class="label-row">
<label for="settings-led-type" data-i18n="device.led_type">LED Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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="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" 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 settings-toggle-group" id="settings-auto-shutdown-group">
<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>
<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-zone-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.openrgb.mode">Zone mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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="zone-mode-radios">
<label class="zone-mode-option">
<input type="radio" name="settings-zone-mode" value="combined" checked>
<span data-i18n="device.openrgb.mode.combined">Combined strip</span>
</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="label-row">
<label for="settings-ws-url" data-i18n="device.ws_url">Connection URL:</label>
<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 class="form-group" id="settings-dmx-protocol-group" style="display: none;">
<div class="label-row">
<label for="settings-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<select id="settings-dmx-protocol">
<option value="artnet">Art-Net</option>
<option value="sacn">sACN (E1.31)</option>
</select>
</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="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 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>
<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>
</section>
<!-- ── 04 · BEHAVIOR ───────────────────────────────── -->
<section class="ds-section" data-ds-key="behavior" 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.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>
</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 class="modal-content" style="max-width: 520px;">
<div class="modal-content">
<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>
</div>
<!-- Tab bar — icon-only so labels never overflow at any locale.
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="settings-layout">
<div class="modal-body">
<!-- ═══ General tab ═══ -->
<div id="settings-panel-general" class="settings-panel active">
<!-- API Keys section (read-only) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.api_keys.label">API Keys</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div>
<!-- ───────────── LEFT RAIL ───────────── -->
<nav class="settings-rail" role="tablist" aria-label="Settings sections">
<div class="settings-rail-group" data-i18n="settings.rail.group.workspace">Workspace</div>
<!-- External URL -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.external_url.label">External URL</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.external_url.hint">If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080</small>
<div style="display:flex;gap:0.5rem;">
<input type="text" id="settings-external-url" placeholder="https://myserver.example.com:8080" style="flex:1" data-i18n-placeholder="settings.external_url.placeholder">
<button class="btn btn-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>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.general">General</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<select id="settings-log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.backup">Backup</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Shutdown action section -->
<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>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.notifications">Notifications</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Server Logs button (opens overlay) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
</div>
<button class="btn btn-secondary" onclick="openLogOverlay()" style="width:100%" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
</div>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.appearance">Appearance</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div>
</div>
<div class="settings-rail-group" data-i18n="settings.rail.group.system">System</div>
<!-- ═══ Backup tab ═══ -->
<div id="settings-panel-backup" class="settings-panel">
<!-- Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.backup.label">Backup Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
</div>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.updates">Updates</span>
<span class="settings-rail-badge" id="settings-rail-update-badge" hidden>1</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Restore section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restore.label">Restore Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
<input type="file" id="settings-restore-input" accept=".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>
<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">
<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>
<span class="settings-rail-label" data-i18n="settings.tab.about">About</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<!-- Partial Export/Import section -->
<!-- Auto-Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
<div class="settings-rail-footer" id="settings-rail-build" aria-hidden="true"></div>
</nav>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
<input type="checkbox" id="auto-backup-enabled">
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
</div>
<!-- ───────────── PANEL BODY ───────────── -->
<div class="modal-body settings-body">
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
<select id="auto-backup-interval" style="width:100%">
<option value="1">1h</option>
<option value="6">6h</option>
<option value="12">12h</option>
<option value="24">24h</option>
<option value="48">48h</option>
<option value="168">7d</option>
</select>
<!-- ═══ General tab ═══ -->
<div id="settings-panel-general" class="settings-panel active">
<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.api_keys">API Keys</span>
<span class="ds-section-meta" id="settings-api-keys-meta" hidden></span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div style="flex:1">
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
<div class="ds-section-body">
<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" class="api-key-list"></div>
</div>
</div>
</div>
</section>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="flex:1" data-i18n="settings.auto_backup.save">Save Settings</button>
<button class="btn btn-secondary" onclick="triggerBackupNow()" style="flex:1" data-i18n="settings.auto_backup.backup_now">Backup Now</button>
</div>
<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>
<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.server">Server</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div style="flex:1">
<label for="update-channel" style="font-size:0.85rem" data-i18n="update.channel_label">Channel</label>
<select id="update-channel">
<option value="false">Stable</option>
<option value="true">Pre-release</option>
</select>
<div class="ds-section-body">
<div class="form-group" id="settings-external-url-group">
<div class="label-row">
<label data-i18n="settings.external_url.label">External URL</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.external_url.hint">If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080</small>
<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>
</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>
<!-- ═══ About tab ═══ -->
<div id="settings-panel-about" class="settings-panel">
<div id="about-panel-content"></div>
</div>
<!-- ═══ Backup tab ═══ -->
<div id="settings-panel-backup" class="settings-panel">
<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>
<!-- Log Viewer Overlay (full-screen, independent of settings modal) -->
<div id="log-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 data-i18n="settings.logs.label">Server Logs</h3>
<select id="log-viewer-filter" onchange="applyLogFilter()">
<option value="all" data-i18n="settings.logs.filter.all">All levels</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
<button id="log-viewer-connect-btn" class="btn btn-secondary btn-sm" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary btn-sm" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
<div 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" 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>
<article class="module log-module" data-ch="cyan">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">LOG · STREAM</span>
<div class="mod-name"><span data-i18n="settings.logs.label">Server Logs</span></div>
<div class="mod-meta">WebSocket · /api/v1/system/logs/ws</div>
</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>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>
<!-- Release Notes Overlay (full-screen, same pattern as log overlay) -->
<div id="release-notes-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeReleaseNotes()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 data-i18n="update.release_notes">Release Notes</h3>
<!-- Release Notes Overlay — v2 "instrument readout" aesthetic, scoped via .release-notes-shell -->
<div id="release-notes-overlay" class="log-overlay release-notes-shell" style="display:none;" data-ch="signal">
<span class="rn-shell__stripe" aria-hidden="true"></span>
<span class="rn-shell__bracket rn-shell__bracket--tl" aria-hidden="true"></span>
<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 id="release-notes-content" class="release-notes-content"></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_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()
_test_config = _original_config.model_copy(
update={
@@ -35,8 +35,15 @@ def _full_prefs() -> dict:
def test_get_returns_defaults_when_unset(client):
"""When no prefs have been saved, GET returns the documented defaults."""
# Wipe via PUT to a known state to make this order-independent.
# (No DELETE endpoint — settings rows are scalar.)
# Wipe the stored row to a falsy value so this test is independent of
# 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")
assert resp.status_code == 200
body = resp.json()