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:
@@ -95,3 +95,5 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
+2364
-196
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────
|
||||
|
||||
@@ -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}">●</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}">●</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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;">✕</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> {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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
@@ -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 ───
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Включить автопроверку",
|
||||
|
||||
@@ -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 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": "启用自动检查",
|
||||
|
||||
@@ -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">📋</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">📋</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>
|
||||
|
||||
@@ -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">✕</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"> </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"> </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">✕</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">▸</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">✕</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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user