feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers

Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
This commit is contained in:
2026-05-28 22:51:04 +03:00
parent b83a72e63f
commit a5effba553
37 changed files with 3068 additions and 145 deletions
+22 -8
View File
@@ -39,8 +39,9 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402
from ledgrab.config import get_config # noqa: E402
from ledgrab.config import Config, get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
from ledgrab.utils.platform import is_windows # noqa: E402
@@ -108,17 +109,28 @@ def _check_port(host: str, port: int) -> None:
sys.exit(1)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
def _build_server(config: Config) -> uvicorn.Server:
"""Construct the uvicorn Server with a bounded graceful-shutdown timeout.
Extracted so the graceful-shutdown bound is unit-testable — leaving it
unset (the uvicorn default of ``None``) is the regression that strands
LED targets and prevents the process from exiting.
"""
uv_config = uvicorn.Config(
"ledgrab.main:app",
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
server = uvicorn.Server(uv_config)
return uvicorn.Server(uv_config)
def main() -> None:
config = get_config()
_check_port(config.server.host, config.server.port)
server = _build_server(config)
set_server(server)
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
@@ -165,9 +177,11 @@ def main() -> None:
tray.run()
# Tray exited — wait for server to finish its graceful shutdown.
# Use a longer join than the lifespan's own ~18 s budget so we don't
# cut the DB checkpoint short on a slow disk.
server_thread.join(timeout=20)
# Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
# first, then the lifespan's own ~16 s shutdown (target restore + DB
# checkpoint). Join longer than their sum so a slow disk doesn't get
# the DB checkpoint cut short.
server_thread.join(timeout=25)
if guard is not None:
guard.stop()
else:
+5
View File
@@ -84,6 +84,8 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
"LEDGRAB_AUTH__API_KEYS."
)
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
uv_config = uvicorn.Config(
"ledgrab.main:app",
host=config.server.host,
@@ -91,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
log_level=config.server.log_level.lower(),
# No uvloop/httptools on Android — use pure-Python asyncio
loop="asyncio",
# Bound the graceful-shutdown wait so stop_server() can't hang forever
# on a lingering WebView events WebSocket — see shutdown_state for why.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
global _server, _loop
+4
View File
@@ -33,6 +33,8 @@ from .routes.audio_processing_templates import router as audio_processing_templa
from .routes.audio_filters import router as audio_filters_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.preferences import router as preferences_router
from .routes.snapshot import router as snapshot_router
from .routes.graph import router as graph_router
router = APIRouter()
router.include_router(system_router)
@@ -66,5 +68,7 @@ router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router)
router.include_router(pattern_templates_router)
router.include_router(preferences_router)
router.include_router(snapshot_router)
router.include_router(graph_router)
__all__ = ["router"]
+4
View File
@@ -80,6 +80,7 @@ def verify_api_key(
if not config.auth.api_keys:
# No keys configured — allow loopback only.
if _is_loopback(client_host):
request.state.auth_label = "anonymous"
return "anonymous"
# Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject.
@@ -123,6 +124,9 @@ def verify_api_key(
# Log successful authentication
logger.debug(f"Authenticated as: {authenticated_as}")
# Stash the friendly label so the access-log middleware can attribute the
# request to a client without re-running the token comparison.
request.state.auth_label = authenticated_as
return authenticated_as
+501
View File
@@ -0,0 +1,501 @@
"""Authoritative wiring-graph schema and topology engine.
This module is the single source of truth for **which reference fields connect
which entity kinds**. The frontend graph editor historically hard-coded the same
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
now serves this registry so the client can render ports and edges generically
and the two never drift.
This registry is a *superset* of the current frontend ``buildGraph``: it also
declares real references that ``buildGraph`` does not yet draw (e.g.
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
The backend is authoritative; the client is expected to converge on it.
Everything in this module is pure (operates on plain dicts), so the topology
build, dependency lookup, cycle and dangling-reference detection are all unit
testable without booting the app or any store.
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
* ``"device_id"`` — a top-level string id.
* ``"brightness.source_id"`` — a nested object; ``brightness`` may be a
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
* ``"settings.pattern_template_id"`` — arbitrarily deep object access.
* ``"layers[].source_id"`` — ``layers`` is a list; read ``source_id``
from every element.
* ``"calibration.lines[].picture_source_id"`` — object → list → field.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, is_dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ConnectionField:
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
target_kind: str
"""Entity kind that *holds* the reference (the consumer / referrer)."""
field: str
"""Dot-path to the reference value (see module docstring grammar)."""
source_kind: str
"""Entity kind being referenced (the producer / source)."""
edge_type: str
"""Edge category, used by the client for colour and port grouping."""
bindable: bool = False
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
nested: bool = False
"""True when the field lives inside a nested object/list (dotted path)."""
@property
def is_list(self) -> bool:
"""True when any path segment iterates a list (``foo[]``)."""
return "[]" in self.field
# ── Entity kinds & their human "type" attribute ────────────────────────────
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
# the entity's subtype (used only for the node label / icon).
NODE_TYPE_FIELD: dict[str, str] = {
"device": "device_type",
"capture_template": "engine_type",
"pp_template": "",
"audio_template": "engine_type",
"pattern_template": "",
"picture_source": "stream_type",
"audio_source": "source_type",
"value_source": "source_type",
"color_strip_source": "source_type",
"sync_clock": "",
"output_target": "target_type",
"scene_preset": "",
"automation": "",
"cspt": "",
}
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
# ── The registry ───────────────────────────────────────────────────────────
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
# omitted — they are not first-class graph node kinds, so wiring them would
# only ever produce dangling-reference noise.
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
# ── Picture sources ──
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
# ── Audio sources ──
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
# ── Value sources ──
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("value_source", "value_source_id", "value_source", "value"),
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
# ── Color strip sources (top-level) ──
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
# ── Color strip sources (BindableFloat value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in (
"smoothing",
"sensitivity",
"intensity",
"scale",
"speed",
"wind_strength",
"temperature_influence",
"sound_volume",
"timeout",
"brightness",
)
),
# ── Color strip sources (BindableColor value bindings) ──
*(
ConnectionField(
"color_strip_source",
f"{prop}.source_id",
"value_source",
"value",
bindable=True,
nested=True,
)
for prop in ("color", "color_peak", "fallback_color", "default_color")
),
# ── Color strip sources (composite layers / mapped zones / calibration) ──
ConnectionField(
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"layers[].brightness_source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
ConnectionField(
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
),
ConnectionField(
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
),
ConnectionField(
"color_strip_source",
"calibration.lines[].picture_source_id",
"picture_source",
"picture",
nested=True,
),
# ── Output targets ──
ConnectionField("output_target", "device_id", "device", "device"),
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
ConnectionField(
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
),
ConnectionField(
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
),
ConnectionField(
"output_target",
"settings.brightness.source_id",
"value_source",
"value",
bindable=True,
nested=True,
),
# ── Scene presets ──
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
# ── Automations ──
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
# ── Devices ──
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
)
def schema_for_kind(kind: str) -> list[ConnectionField]:
"""Every connectable field whose *referrer* is ``kind``."""
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
def schema_as_dicts() -> list[dict[str, Any]]:
"""Serialize the registry for the ``/graph/schema`` endpoint."""
return [
{
"target_kind": c.target_kind,
"field": c.field,
"source_kind": c.source_kind,
"edge_type": c.edge_type,
"bindable": c.bindable,
"nested": c.nested,
"is_list": c.is_list,
}
for c in CONNECTION_SCHEMA
]
# ── Reference extraction ────────────────────────────────────────────────────
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
Returns only non-empty string ids. Tolerant of missing keys, ``None``
values and unbound bindables (a plain number where an object was expected).
"""
current: list[Any] = [entity]
for segment in field_path.split("."):
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in current:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif val is not None:
nxt.append(val)
current = nxt
return [v for v in current if isinstance(v, str) and v]
def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use.
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
invokes no managers), falling back to ``to_dict()`` then ``{}``.
"""
if is_dataclass(model) and not isinstance(model, type):
try:
return asdict(model)
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
to_dict = getattr(model, "to_dict", None)
if callable(to_dict):
try:
result = to_dict()
if isinstance(result, dict):
return result
except Exception as exc: # noqa: BLE001
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
logger.warning(
"graph: could not serialize model %r; excluding from graph", type(model).__name__
)
return {}
# ── Topology / validation ───────────────────────────────────────────────────
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
eid = entity.get("id")
if not isinstance(eid, str) or not eid:
return None
type_field = NODE_TYPE_FIELD.get(kind, "")
subtype = entity.get(type_field, "") if type_field else ""
return {
"id": eid,
"kind": kind,
"name": entity.get("name") or eid,
"type": subtype if isinstance(subtype, str) else "",
}
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""Build the full wiring graph + a validation report.
Args:
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
``broken_refs``, ``cycles``).
"""
nodes: list[dict[str, Any]] = []
node_ids: set[str] = set()
for kind in ENTITY_KINDS:
for entity in entities_by_kind.get(kind, []):
node = _node_from(kind, entity)
if node and node["id"] not in node_ids:
node_ids.add(node["id"])
nodes.append(node)
edges: list[dict[str, Any]] = []
broken_refs: list[dict[str, str]] = []
for cf in CONNECTION_SCHEMA:
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str) or not referrer:
continue
for ref in extract_refs(entity, cf.field):
if ref not in node_ids:
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
continue
edges.append(
{
"from": ref,
"to": referrer,
"field": cf.field,
"edge_type": cf.edge_type,
"nested": cf.nested,
}
)
connected: set[str] = set()
for e in edges:
connected.add(e["from"])
connected.add(e["to"])
orphans = sorted(nid for nid in node_ids if nid not in connected)
cycles = sorted(detect_cycles(edges))
return {
"nodes": nodes,
"edges": edges,
"issues": {
"orphans": orphans,
"broken_refs": broken_refs,
"cycles": cycles,
},
}
def find_dependents(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> list[dict[str, str]]:
"""Return every entity that references ``(kind, entity_id)``.
``kind`` is the kind of the *referenced* entity; matching schema entries are
those whose ``source_kind == kind``.
"""
name_by_id: dict[str, str] = {}
for k in ENTITY_KINDS:
for entity in entities_by_kind.get(k, []):
eid = entity.get("id")
if isinstance(eid, str):
name_by_id[eid] = entity.get("name") or eid
dependents: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for cf in CONNECTION_SCHEMA:
if cf.source_kind != kind:
continue
for entity in entities_by_kind.get(cf.target_kind, []):
referrer = entity.get("id")
if not isinstance(referrer, str):
continue
if entity_id in extract_refs(entity, cf.field):
key = (referrer, cf.field)
if key in seen:
continue
seen.add(key)
dependents.append(
{
"id": referrer,
"kind": cf.target_kind,
"name": name_by_id.get(referrer, referrer),
"field": cf.field,
}
)
return dependents
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
"""Return every node id that participates in a directed cycle (from→to)."""
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
in_cycle: set[str] = set()
for start in list(adj.keys()):
if color.get(start, WHITE) != WHITE:
continue
stack: list[tuple[str, int]] = [(start, 0)]
path: list[str] = [start]
color[start] = GRAY
while stack:
node, idx = stack[-1]
neighbors = adj.get(node, [])
if idx < len(neighbors):
stack[-1] = (node, idx + 1)
nxt = neighbors[idx]
c = color.get(nxt, WHITE)
if c == GRAY:
if nxt in path:
i = path.index(nxt)
in_cycle.update(path[i:])
elif c == WHITE:
color[nxt] = GRAY
path.append(nxt)
stack.append((nxt, 0))
else:
color[node] = BLACK
if path and path[-1] == node:
path.pop()
stack.pop()
return in_cycle
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
if start == goal:
return True
adj: dict[str, list[str]] = {}
for e in edges:
adj.setdefault(e["from"], []).append(e["to"])
seen = {start}
queue = [start]
while queue:
cur = queue.pop()
for nxt in adj.get(cur, []):
if nxt == goal:
return True
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return False
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
the existing data-flow edges (so the new edge would close the loop), or the
two are the same node.
"""
if source_id == target_id:
return True
return _reachable(edges, target_id, source_id)
def _entity_exists(
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
) -> bool:
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
def validate_connection(
entities_by_kind: dict[str, list[dict[str, Any]]],
target_kind: str,
target_id: str,
field: str,
source_id: str,
) -> tuple[bool, str | None]:
"""Validate a proposed wiring edit before it is persisted.
Checks, in order: the field is a known connectable reference; the target
exists; (when not detaching) the source exists and is of the registry's
expected kind; and the edit would not create a dependency cycle. Returns
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
"""
cf = next(
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
None,
)
if cf is None:
return False, f"Unknown connection field: {target_kind}.{field}"
if cf.is_list:
# List slots (layers/zones/scene targets) hold many edges sharing the
# same (to, field); without an element index this endpoint can't model
# which one is being replaced for the cycle check. Edit those via the
# entity editor.
return False, f"List connection '{field}' must be edited via the entity editor"
if not _entity_exists(entities_by_kind, target_kind, target_id):
return False, f"Target entity not found: {target_id}"
if not source_id:
return True, None # detaching a slot is always valid
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
return False, f"Source {cf.source_kind} not found: {source_id}"
# Cycle check: ignore the edge currently occupying this slot, since the
# write replaces it.
topo = build_topology(entities_by_kind)
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
if would_create_cycle(edges, source_id, target_id):
return False, "Connection would create a dependency cycle"
return True, None
@@ -0,0 +1,25 @@
"""Shared MQTT-source validation for route handlers.
Both the device routes and the output-target routes accept an
``mqtt_source_id`` that must reference an existing ``MQTTSource``. This module
is the single source of truth for that check so the two callers cannot drift.
"""
from fastapi import HTTPException
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
def validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str | None) -> None:
"""Ensure a referenced MQTT source exists.
Empty / ``None`` is allowed (unconfigured = "first available broker").
Raises ``HTTPException(422)`` if a non-empty id does not resolve.
"""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
+41
View File
@@ -13,6 +13,7 @@ from ledgrab.core.devices.led_client import (
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_mqtt_store,
get_output_target_store,
get_processor_manager,
)
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
)
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.utils.url_scheme import infer_http_scheme
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__)
router = APIRouter()
@@ -105,6 +109,7 @@ def _device_to_response(device) -> DeviceResponse:
gamesense_device_type=device.gamesense_device_type,
ble_family=device.ble_family,
ble_govee_key=device.ble_govee_key,
mqtt_source_id=getattr(device, "mqtt_source_id", "") or "",
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
@@ -124,11 +129,13 @@ async def create_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Create and attach a new LED device."""
try:
device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}")
validate_mqtt_source_exists(mqtt_store, device_data.mqtt_source_id)
# ── Group device: validate children + compute LED count ──
if device_type == "group":
@@ -287,6 +294,7 @@ async def create_device(
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
ble_family=device_data.ble_family or "",
ble_govee_key=device_data.ble_govee_key or "",
mqtt_source_id=device_data.mqtt_source_id or "",
group_device_ids=group_device_ids,
group_mode=group_mode,
)
@@ -543,12 +551,14 @@ async def update_device(
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Update device information."""
try:
# Group-specific validation before applying update
existing = store.get_device(device_id)
is_group = existing.device_type == "group"
validate_mqtt_source_exists(mqtt_store, update_data.mqtt_source_id)
# Normalize URL the same way we do on create:
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
@@ -634,6 +644,7 @@ async def update_device(
gamesense_device_type=update_data.gamesense_device_type,
ble_family=update_data.ble_family,
ble_govee_key=update_data.ble_govee_key,
mqtt_source_id=update_data.mqtt_source_id,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
icon=update_data.icon,
@@ -669,6 +680,10 @@ async def update_device(
fire_entity_event("device", "updated", device_id)
return _device_to_response(device)
except HTTPException:
# Intentional 4xx (e.g. unknown mqtt_source_id, group validation)
# must propagate unchanged — not be masked as a 500.
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
@@ -777,6 +792,32 @@ async def ping_device(
# ===== WLED BRIGHTNESS ENDPOINTS =====
async def resolve_device_brightness(device, manager: ProcessorManager) -> int | None:
"""Resolve a device's current brightness for aggregate/batch reads.
Mirrors GET /brightness but degrades to ``None`` instead of raising, so one
unreachable device can't fail a whole snapshot. Reads the server-side cache
first and only touches hardware when the cache is cold, then populates it so
subsequent reads are I/O-free.
"""
if "brightness_control" not in get_device_capabilities(device.device_type):
return None
ds = manager.find_device_state(device.id)
if ds and ds.hardware_brightness is not None:
return ds.hardware_brightness
try:
provider = get_provider(device.device_type)
bri = await provider.get_brightness(device.url)
if ds:
ds.hardware_brightness = bri
return bri
except NotImplementedError:
return device.software_brightness
except Exception as e:
logger.warning("Failed to resolve brightness for device %s: %s", device.id, e)
return None
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness(
device_id: str,
+124
View File
@@ -0,0 +1,124 @@
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
These power the visual graph editor (and any other client) with a single
authoritative view of how entities are wired together:
* ``GET /api/v1/graph/schema`` — the connectable-field registry.
* ``GET /api/v1/graph`` — nodes + edges + validation.
* ``GET /api/v1/graph/dependents/{kind}/{id}`` — what references an entity.
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
this layer only gathers serialized entities from the stores and delegates.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from ledgrab.api import dependencies as deps
from ledgrab.api.auth import AuthRequired
from ledgrab.api.graph_schema import (
ENTITY_KINDS,
NODE_TYPE_FIELD,
build_topology,
find_dependents,
schema_as_dicts,
serialize_entity,
validate_connection,
)
logger = logging.getLogger(__name__)
class ConnectionValidationRequest(BaseModel):
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
target_kind: str
target_id: str
field: str
source_id: str = Field(default="", description="Empty string detaches the slot.")
router = APIRouter()
# kind → dependency getter for the store that owns that entity kind.
_KIND_STORES: dict[str, Callable[[], Any]] = {
"device": deps.get_device_store,
"capture_template": deps.get_template_store,
"pp_template": deps.get_pp_template_store,
"audio_template": deps.get_audio_template_store,
"pattern_template": deps.get_pattern_template_store,
"picture_source": deps.get_picture_source_store,
"audio_source": deps.get_audio_source_store,
"value_source": deps.get_value_source_store,
"color_strip_source": deps.get_color_strip_store,
"sync_clock": deps.get_sync_clock_store,
"output_target": deps.get_output_target_store,
"scene_preset": deps.get_scene_preset_store,
"automation": deps.get_automation_store,
"cspt": deps.get_cspt_store,
}
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
out: dict[str, list[dict[str, Any]]] = {}
for kind, getter in _KIND_STORES.items():
try:
store = getter()
models = store.get_all()
except (
Exception
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
out[kind] = []
continue
out[kind] = [serialize_entity(m) for m in models]
return out
@router.get("/api/v1/graph/schema", tags=["Graph"])
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
"""Return the authoritative registry of connectable reference fields."""
return {
"kinds": list(ENTITY_KINDS),
"node_type_field": NODE_TYPE_FIELD,
"connections": schema_as_dicts(),
}
@router.get("/api/v1/graph", tags=["Graph"])
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
"""Return the full wiring topology (nodes + edges) and a validation report."""
entities = await run_in_threadpool(_gather_entities)
return build_topology(entities)
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
"""Return every entity that references ``(kind, entity_id)``."""
if kind not in ENTITY_KINDS:
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
entities = await run_in_threadpool(_gather_entities)
return {"dependents": find_dependents(entities, kind, entity_id)}
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
async def validate_graph_connection(
body: ConnectionValidationRequest, _auth: AuthRequired
) -> dict[str, Any]:
"""Validate a proposed wiring edit (existence + source kind + no cycle).
The graph editor calls this before persisting a drag-connect so it can
refuse edits that would dangle a reference or create a dependency loop.
"""
entities = await run_in_threadpool(_gather_entities)
ok, error = validate_connection(
entities, body.target_kind, body.target_id, body.field, body.source_id
)
return {"ok": ok, "error": error}
@@ -49,6 +49,8 @@ from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
from ._mqtt_validation import validate_mqtt_source_exists
logger = get_logger(__name__)
router = APIRouter()
@@ -270,16 +272,6 @@ def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
@@ -333,7 +325,7 @@ async def create_target(
case Z2MLightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target(
name=data.name,
description=data.description,
@@ -540,7 +532,7 @@ async def update_target(
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.update_z2m_light_target(
target_id,
name=data.name,
+201
View File
@@ -0,0 +1,201 @@
"""Aggregated snapshot endpoint for low-overhead polling clients.
Returns, in a single response, everything the Home Assistant integration's
coordinator needs per poll: all output targets with processing state + metrics,
all devices with brightness, the color-strip / value-source / scene-preset /
sync-clock lists, and the system block (performance, health, update).
This collapses the integration's previous ~2N+M request fan-out (per-target
``/state`` + ``/metrics`` and per-device ``/brightness``) into one round trip.
The handler delegates to the existing list/batch route handlers so the response
sub-shapes stay byte-identical to the individual endpoints — no shaping logic is
duplicated here.
Callers that don't need the whole payload can pass ``?include=`` with a
comma-separated subset of section names (the response keys). Omitting it returns
every section. Gating is per section, so an excluded section also skips its
server-side work — dropping ``device_brightness`` avoids cold-cache hardware
probes, and dropping ``system`` skips the (blocking) NVML performance query.
"""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
get_color_strip_store,
get_device_store,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
get_sync_clock_manager,
get_sync_clock_store,
get_update_service,
get_value_source_store,
)
from ledgrab.api.schemas.update import UpdateStatusResponse
from ledgrab.utils import get_logger
from .color_strip_sources.crud import list_color_strip_sources
from .devices import list_devices, resolve_device_brightness
from .output_targets import batch_target_metrics, batch_target_states, list_targets
from .scene_presets import list_scene_presets
from .sync_clocks import list_sync_clocks
from .system import get_system_performance, health_check
from .update import get_update_status
from .value_sources import list_value_sources
logger = get_logger(__name__)
router = APIRouter()
# Selectable snapshot sections — these are exactly the response top-level keys.
SNAPSHOT_SECTIONS = (
"targets",
"target_states",
"target_metrics",
"devices",
"device_brightness",
"css_sources",
"value_sources",
"scene_presets",
"sync_clocks",
"system",
)
_SECTION_SET = frozenset(SNAPSHOT_SECTIONS)
def _resolve_sections(include: str | None) -> frozenset[str]:
"""Validate the ``include`` query param into the set of sections to emit.
``None``/empty → every section. Unknown names are rejected with 422 so a
typo fails loudly instead of silently returning a smaller payload.
"""
if not include:
return _SECTION_SET
requested = {part.strip() for part in include.split(",") if part.strip()}
unknown = requested - _SECTION_SET
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"Unknown snapshot section(s): {', '.join(sorted(unknown))}. "
f"Valid sections: {', '.join(SNAPSHOT_SECTIONS)}."
),
)
return frozenset(requested)
async def _safe_section(awaitable, label: str):
"""Await a section, degrading to ``None`` on failure instead of 500-ing.
The snapshot is a resilience-oriented poll surface: one failing section
(e.g. NVML performance probing) must not fail the whole response. This
preserves the per-section fault isolation the HA coordinator relied on
before these calls were merged into one request — the coordinator already
tolerates a ``None`` section.
"""
try:
return await awaitable
except Exception:
logger.warning("snapshot: section %r failed, returning null", label, exc_info=True)
return None
async def _update_status_model(_auth, update_service) -> UpdateStatusResponse:
"""Fetch update status and coerce it through the response model.
The standalone ``/system/update/status`` endpoint declares
``response_model=UpdateStatusResponse``; coercing here keeps the snapshot's
``system.update`` field identical to that endpoint rather than emitting the
service's raw dict unfiltered.
"""
raw = await get_update_status(_auth, update_service)
return UpdateStatusResponse.model_validate(raw)
@router.get("/api/v1/snapshot", tags=["Snapshot"])
async def get_snapshot(
request: Request,
_auth: AuthRequired,
include: str | None = Query(
None,
description=(
"Comma-separated subset of sections to include. Omit for all. "
"Valid: " + ", ".join(SNAPSHOT_SECTIONS)
),
),
manager=Depends(get_processor_manager),
target_store=Depends(get_output_target_store),
device_store=Depends(get_device_store),
css_store=Depends(get_color_strip_store),
value_store=Depends(get_value_source_store),
preset_store=Depends(get_scene_preset_store),
clock_store=Depends(get_sync_clock_store),
clock_manager=Depends(get_sync_clock_manager),
update_service=Depends(get_update_service),
) -> dict[str, Any]:
"""Return the full poll payload (or a requested subset) in one response.
Shape (a key is present only when its section is requested)::
{
"targets": [<OutputTargetResponse>, ...],
"target_states": {target_id: <state>, ...},
"target_metrics": {target_id: <metrics>, ...},
"devices": [<DeviceResponse>, ...],
"device_brightness": {device_id: int | null, ...},
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
"""
sections = _resolve_sections(include)
result: dict[str, Any] = {}
if "targets" in sections:
result["targets"] = (await list_targets(_auth, target_store)).targets
if "target_states" in sections:
result["target_states"] = (await batch_target_states(_auth, manager))["states"]
if "target_metrics" in sections:
result["target_metrics"] = (await batch_target_metrics(_auth, manager))["metrics"]
if "devices" in sections:
result["devices"] = (await list_devices(_auth, device_store)).devices
if "device_brightness" in sections:
device_models = device_store.get_all_devices()
brightness_values = await asyncio.gather(
*(resolve_device_brightness(d, manager) for d in device_models),
return_exceptions=True,
)
result["device_brightness"] = {
model.id: (None if isinstance(value, BaseException) else value)
for model, value in zip(device_models, brightness_values)
}
if "css_sources" in sections:
css = await list_color_strip_sources(_auth, css_store, manager)
result["css_sources"] = css.sources
if "value_sources" in sections:
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
if "scene_presets" in sections:
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
if "sync_clocks" in sections:
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
result["sync_clocks"] = clocks.clocks
if "system" in sections:
result["system"] = {
"performance": await _safe_section(
run_in_threadpool(get_system_performance, _auth), "system.performance"
),
"health": await _safe_section(health_check(request), "system.health"),
"update": await _safe_section(
_update_status_model(_auth, update_service), "system.update"
),
}
return result
+11
View File
@@ -131,6 +131,11 @@ class DeviceCreate(BaseModel):
None,
description="Govee AES key (hex) — required for encrypted Govee firmware",
)
# MQTT (multi-broker) field
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) ID for device_type=mqtt. Empty = first available broker.",
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
@@ -217,6 +222,9 @@ class DeviceUpdate(BaseModel):
ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
)
mqtt_source_id: str | None = Field(
None, description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
@@ -436,6 +444,9 @@ class DeviceResponse(BaseModel):
ble_govee_key: str = Field(
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
)
mqtt_source_id: str = Field(
default="", description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID"
)
+8
View File
@@ -15,6 +15,7 @@ def main():
import uvicorn
from ledgrab.config import get_config
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
config = get_config()
uvicorn.run(
@@ -22,7 +23,14 @@ def main():
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
# Access logging is handled by the _access_log middleware (with token
# attribution); disable uvicorn's to avoid duplicate lines.
access_log=False,
reload=False,
# Bound the graceful-shutdown wait so Ctrl+C with the UI open still
# runs the lifespan shutdown instead of hanging on a lingering events
# WebSocket — see shutdown_state.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
+37 -1
View File
@@ -2,6 +2,7 @@
import asyncio
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Awaitable
@@ -74,7 +75,7 @@ config = get_config()
# The shutdown-complete signal is owned by a leaf module so ``__main__``
# can import it without dragging in this module's heavy global state.
from ledgrab.shutdown_state import shutdown_complete # noqa: E402
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT, shutdown_complete # noqa: E402
def _migrate_legacy_data_location() -> None:
@@ -577,6 +578,33 @@ async def _security_headers(request: Request, call_next):
return response
# Middleware: structured access log enriched with the authenticated token's
# friendly label (the key name from auth.api_keys), so requests can be
# attributed to a specific client (e.g. "homeassistant" vs "android"). The
# label is set onto request.state by verify_api_key; endpoints without auth
# (or failed auth) log "unauthenticated". Only the label is logged — never the
# token secret. Registered last so it runs outermost: it measures total
# handling time and always records the final status, even on error.
@app.middleware("http")
async def _access_log(request: Request, call_next):
start = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
logger.info(
"http_request",
method=request.method,
path=request.url.path,
status=status_code,
token=getattr(request.state, "auth_label", None) or "unauthenticated",
client=request.client.host if request.client else None,
duration_ms=round((time.perf_counter() - start) * 1000, 1),
)
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
# Re-add the docs endpoints we disabled above, now protected by the same
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
@@ -645,5 +673,13 @@ if __name__ == "__main__":
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
# Our _access_log middleware emits a richer structured line (incl. the
# authenticated token label), so suppress uvicorn's default access log
# to avoid two lines per request.
access_log=False,
reload=False, # Disabled due to watchfiles infinite reload loop
# Bound the graceful-shutdown wait so Ctrl+C with the UI open still
# runs the lifespan shutdown (stop targets + DB checkpoint) instead of
# hanging on a lingering events WebSocket — see shutdown_state.
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
+12
View File
@@ -16,3 +16,15 @@ they release Windows / unblock only once cleanup is genuinely done.
import threading
shutdown_complete: threading.Event = threading.Event()
# Bound uvicorn's graceful-shutdown wait (``uvicorn.Config(timeout_graceful_shutdown=...)``).
# uvicorn defaults this to ``None`` — ``Server.shutdown()`` then waits *forever*
# for open connections (and their tasks) to drain before it runs the lifespan
# shutdown. The events WebSocket handler blocks on ``queue.get()`` and the
# browser auto-reconnects, so connections never drain on their own. Without a
# bound, the lifespan shutdown — which stops LED targets and checkpoints the
# DB — never runs: targets stay lit and the process can't exit (leftover
# processor threads). Shared by both the desktop (__main__) and Android
# (android_entry) launchers. Keep it small so OS-shutdown cleanup still fits
# Windows' ~20 s budget; it is spent BEFORE the lifespan's own ~16 s budget.
GRACEFUL_SHUTDOWN_TIMEOUT: int = 3
@@ -495,6 +495,18 @@ html:has(#tab-graph.active) {
opacity: 0.95;
}
/* Custom per-entity icon: the embedded SVG strokes with currentColor, so it is
tinted via `color` (default muted; the node's icon_color overrides inline). */
.graph-node-custom-icon {
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.9;
}
.graph-node.running .graph-node-custom-icon {
color: var(--ch-signal, var(--primary-color));
opacity: 1;
}
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
.graph-node.running .graph-node-body {
@@ -588,6 +600,21 @@ html:has(#tab-graph.active) {
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
}
/* Whole-node drop targets: a source can be dropped on any compatible node to
wire one of its slots — including empty slots that have no input port yet. */
.graph-svg.connecting .graph-node-compatible .graph-node-body {
stroke: var(--ch-signal, var(--primary-color));
stroke-dasharray: 4 3;
stroke-width: 1.5;
}
.graph-node-drop-target .graph-node-body {
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 2.5 !important;
stroke-dasharray: none !important;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent));
}
/* ── Edges ── */
.graph-edge {
@@ -630,6 +657,25 @@ html:has(#tab-graph.active) {
opacity: 0.4;
}
/* Edge field labels — hidden until zoomed in enough to read them. */
.graph-edge-label {
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono, monospace);
fill: var(--text-secondary);
paint-order: stroke;
stroke: var(--lux-bg-1, var(--card-bg));
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.graph-edges.show-labels .graph-edge-label {
opacity: 0.85;
}
/* Edge type colors */
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
@@ -788,6 +834,17 @@ html:has(#tab-graph.active) {
stroke-dasharray: 4 3;
}
/* ── Health overlay: configuration issues (broken refs / cycles) ── */
.graph-node.has-issue .graph-node-body {
stroke: var(--danger-color);
stroke-width: 2;
stroke-dasharray: 5 3;
}
.graph-node-issue {
color: var(--danger-color);
}
/* ── Search highlight ── */
.graph-node.search-match .graph-node-body {
@@ -1001,6 +1058,33 @@ html:has(#tab-graph.active) {
display: inline-block;
}
/* Issues toolbar button + count badge */
.graph-issues-btn {
position: relative;
color: var(--danger-color);
}
.graph-issues-count {
position: absolute;
top: 0;
right: 0;
transform: translate(35%, -35%);
background: var(--danger-color);
color: #fff;
border-radius: 8px;
font-size: 0.6rem;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
padding: 0 3px;
}
.graph-issues-count:empty {
display: none;
}
.graph-filter-types-popover {
display: none;
flex-direction: column;
+3 -1
View File
@@ -207,7 +207,7 @@ import {
import {
loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
} from './features/graph-editor.ts';
@@ -625,6 +625,8 @@ Object.assign(window, {
graphZoomIn,
graphZoomOut,
graphRelayout,
graphShowIssues,
graphExportTopology,
graphToggleFullscreen,
graphAddEntity,
toggleToolbarOverflow,
@@ -3,12 +3,65 @@
* Supports creating, changing, and detaching connections via the graph editor.
*/
import { apiPut } from './api-client.ts';
import { apiPut, apiPost, apiGet } from './api-client.ts';
import {
streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj,
} from './state.ts';
/** Result of the backend pre-write connection validator. */
export interface ConnectionValidation {
ok: boolean;
error?: string | null;
}
/**
* Ask the backend whether a proposed wiring edit is valid (target/source exist,
* source is the right kind, and it would not create a dependency cycle).
*
* Fails *open*: if the validation endpoint is unavailable we return ``ok`` so
* wiring still works against older servers — the per-entity PUT remains the
* source of truth, this is just an early, friendlier guard.
*/
export async function validateConnection(
targetKind: string, targetId: string, field: string, sourceId: string,
): Promise<ConnectionValidation> {
try {
return await apiPost<ConnectionValidation>('/graph/validate-connection', {
target_kind: targetKind,
target_id: targetId,
field,
source_id: sourceId,
});
} catch {
return { ok: true };
}
}
/** An entity that references another entity (one row of the dependents query). */
export interface GraphDependent {
id: string;
kind: string;
name: string;
field: string;
}
/**
* List every entity that references ``(kind, id)``. Used to warn before a
* delete would dangle other entities' references. Fails *safe* (empty list)
* if the endpoint is unavailable.
*/
export async function getDependents(kind: string, id: string): Promise<GraphDependent[]> {
try {
const res = await apiGet<{ dependents: GraphDependent[] }>(
`/graph/dependents/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
);
return res.dependents || [];
} catch {
return [];
}
}
/* ── Types ────────────────────────────────────────────────────── */
interface ConnectionEntry {
@@ -19,6 +72,13 @@ interface ConnectionEntry {
endpoint?: string;
cache?: { invalidate(): void };
nested?: boolean;
/**
* A single-level value-source binding (e.g. `brightness.source_id`). These
* are structurally nested but ARE drag-editable: the write goes through the
* entity's `BindableFloat.apply_update`, which merges `{source_id}` while
* preserving the static value. (List/double-nested fields stay read-only.)
*/
bindable?: boolean;
}
interface CompatibleInput {
@@ -61,26 +121,26 @@ const CONNECTION_MAP: ConnectionEntry[] = [
// Output targets
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true },
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
// Automations
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
// ── BindableFloat value source edges (CSS properties) ──
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// ── BindableFloat value source edges (CSS properties) — drag-editable ──
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache, nested: true, bindable: true },
// HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
// ── BindableColor value source edges (CSS color properties) ──
{ targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
@@ -97,12 +157,23 @@ const CONNECTION_MAP: ConnectionEntry[] = [
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
];
/** Editable via the graph: top-level reference fields, plus single-level
* bindable value-source slots (list/double-nested fields stay read-only). */
function _isEditable(c: ConnectionEntry): boolean {
return !c.nested || !!c.bindable;
}
/** True when a field is a bindable slot (its parent is a `Bindable*`). */
export function isBindableField(targetKind: string, field: string): boolean {
return !!CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field)?.bindable;
}
/**
* Check if an edge (by field name) is editable via drag-connect.
*/
export function isEditableEdge(field: string): boolean {
const entry = CONNECTION_MAP.find(c => c.field === field);
return entry ? !entry.nested : false;
return entry ? _isEditable(entry) : false;
}
/**
@@ -111,7 +182,7 @@ export function isEditableEdge(field: string): boolean {
*/
export function findConnection(targetKind: string, sourceKind: string, edgeType?: string): ConnectionEntry[] {
return CONNECTION_MAP.filter(c =>
!c.nested &&
_isEditable(c) &&
c.targetKind === targetKind &&
c.sourceKind === sourceKind &&
(!edgeType || c.edgeType === edgeType)
@@ -124,7 +195,7 @@ export function findConnection(targetKind: string, sourceKind: string, edgeType?
*/
export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
return CONNECTION_MAP
.filter(c => !c.nested && c.sourceKind === sourceKind)
.filter(c => _isEditable(c) && c.sourceKind === sourceKind)
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
}
@@ -132,7 +203,7 @@ export function getCompatibleInputs(sourceKind: string): CompatibleInput[] {
* Find the connection entry for a specific edge (by target kind and field).
*/
export function getConnectionByField(targetKind: string, field: string): ConnectionEntry | undefined {
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
}
/**
@@ -144,11 +215,16 @@ export function getConnectionByField(targetKind: string, field: string): Connect
* @returns {Promise<boolean>} success
*/
export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise<boolean> {
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
if (!entry) return false;
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
if (!entry || !entry.endpoint) return false;
const url = entry.endpoint!.replace('{id}', targetId);
const body = { [field]: newSourceId };
const url = entry.endpoint.replace('{id}', targetId);
// For a bindable slot (`<parent>.source_id`) PUT `{ <parent>: { source_id } }`
// so the backend's `Bindable*.apply_update` merges and preserves the static
// value/colour. Top-level fields keep the flat `{ field: id }` shape.
const body: Record<string, unknown> = entry.bindable
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
: { [field]: newSourceId };
try {
await apiPut(url, body);
@@ -52,6 +52,43 @@ export function renderEdges(group: SVGGElement, edges: GraphEdge[]): void {
const path = _renderEdge(edge);
group.appendChild(path);
}
// Field labels rendered last so they sit above the paths. Hidden by
// default — revealed when zoomed in (`.show-labels`) or on highlight.
for (const edge of edges) {
const label = _renderEdgeLabel(edge);
if (label) group.appendChild(label);
}
}
/** Human-readable label for a reference field, e.g. `capture_template_id` → `capture template`. */
function _edgeFieldLabel(field: string): string {
return field.replace(/_id$/, '').replace(/\./g, ' ').replace(/_/g, ' ').trim();
}
/** Midpoint of the port-aware cubic bezier (its control points are horizontal
* offsets only, so the t=0.5 point is exactly the endpoint midpoint). */
function _edgeMidpoint(fromNode: GraphNodeRect, toNode: GraphNodeRect, fromPortY?: number, toPortY?: number): { x: number; y: number } {
const x1 = fromNode.x + fromNode.width;
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
const x2 = toNode.x;
const y2 = toNode.y + (toPortY ?? toNode.height / 2);
return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
}
function _renderEdgeLabel(edge: GraphEdge): SVGElement | null {
if (!edge.field || !edge.fromNode || !edge.toNode) return null;
const mid = _edgeMidpoint(edge.fromNode, edge.toNode, edge.fromPortY, edge.toPortY);
const text = svgEl('text', {
class: `graph-edge-label graph-edge-label-${edge.type}`,
x: mid.x, y: mid.y - 4,
'text-anchor': 'middle',
'data-from': edge.from,
'data-to': edge.to,
'data-field': edge.field,
});
text.textContent = _edgeFieldLabel(edge.field);
return text;
}
function _createArrowMarker(type: string): SVGElement {
@@ -263,6 +300,14 @@ export function updateEdgesForNode(group: SVGGElement, nodeId: string, nodeMap:
pathEl.setAttribute('d', d);
}
});
// Keep the field label pinned to the edge midpoint while dragging.
const mid = _edgeMidpoint(fromNode, toNode, edge.fromPortY, edge.toPortY);
group.querySelectorAll(`.graph-edge-label[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(lbl => {
if ((lbl.getAttribute('data-field') || '') === (edge.field || '')) {
lbl.setAttribute('x', String(mid.x));
lbl.setAttribute('y', String(mid.y - 4));
}
});
}
}
@@ -13,6 +13,8 @@ interface LayoutNode {
name: string;
subtype: string;
tags: string[];
icon?: string;
iconColor?: string;
running?: boolean;
x?: number;
y?: number;
@@ -33,6 +35,17 @@ interface LayoutResult {
nodes: Map<string, LayoutNode>;
edges: (LayoutEdge & { points: { x: number; y: number }[] | null; fromNode: LayoutNode; toNode: LayoutNode })[];
bounds: { x: number; y: number; width: number; height: number };
brokenRefs: BrokenRef[];
}
/** A reference field that points at an entity which no longer exists. */
export interface BrokenRef {
/** The missing (referenced) entity id. */
ref: string;
/** The id of the entity that still holds the dangling reference. */
by: string;
/** The reference field name on the referrer. */
field: string;
}
interface PortSet {
@@ -81,7 +94,7 @@ const ELK_OPTIONS = {
*/
export async function computeLayout(entities: EntitiesInput): Promise<LayoutResult> {
const elk = new ELK();
const { nodes: nodeList, edges: edgeList } = buildGraph(entities);
const { nodes: nodeList, edges: edgeList, brokenRefs } = buildGraph(entities);
const elkGraph = {
id: 'root',
@@ -151,7 +164,7 @@ export async function computeLayout(entities: EntitiesInput): Promise<LayoutResu
? { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
: { x: 0, y: 0, width: 400, height: 300 };
return { nodes: nodeMap, edges, bounds };
return { nodes: nodeMap, edges, bounds, brokenRefs };
}
/* ── Entity color mapping ── */
@@ -207,22 +220,36 @@ function edgeType(fromKind: string, toKind: string, field: string): string {
/* ── Graph builder ── */
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[]; brokenRefs: BrokenRef[] } {
const nodes: LayoutNode[] = [];
const edges: LayoutEdge[] = [];
const brokenRefs: BrokenRef[] = [];
const nodeIds = new Set<string>();
// Index nodes by id so edge-building is O(1) instead of O(N) per edge.
const nodeByIdLocal = new Map<string, LayoutNode>();
function addNode(id: string, kind: string, name: string, subtype: string, extra: Record<string, any> = {}): void {
if (!id || nodeIds.has(id)) return;
nodeIds.add(id);
nodes.push({ id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra });
const node = { id, kind, name: name || id, subtype: subtype || '', tags: extra.tags || [], ...extra };
nodes.push(node);
nodeByIdLocal.set(id, node);
}
function addEdge(from: string, to: string, field: string, label: string = ''): void {
if (!from || !to || !nodeIds.has(from) || !nodeIds.has(to)) return;
if (!from || !to) return;
// The referrer (`to`) is always a current entity in these loops; if the
// referenced entity (`from`) is missing, the reference is dangling —
// record it so the editor can surface a "broken reference" warning
// instead of silently dropping the edge (the old behaviour).
if (!nodeIds.has(from)) {
if (nodeIds.has(to)) brokenRefs.push({ ref: from, by: to, field });
return;
}
if (!nodeIds.has(to)) return;
const type = edgeType(
nodes.find(n => n.id === from)?.kind ?? '',
nodes.find(n => n.id === to)?.kind ?? '',
nodeByIdLocal.get(from)?.kind ?? '',
nodeByIdLocal.get(to)?.kind ?? '',
field
);
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
@@ -230,74 +257,76 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
edges.push({ from, to, field, label, type, editable });
}
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
// so node rendering can honour them (parity with custom node colours).
// 1. Devices
for (const d of e.devices || []) {
addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags });
addNode(d.id, 'device', d.name, d.device_type, { tags: d.tags, icon: d.icon, iconColor: d.icon_color });
}
// 2. Capture templates
for (const t of e.captureTemplates || []) {
addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags });
addNode(t.id, 'capture_template', t.name, t.engine_type, { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// 3. PP templates
for (const t of e.ppTemplates || []) {
addNode(t.id, 'pp_template', t.name, '', { tags: t.tags });
addNode(t.id, 'pp_template', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// 4. Audio templates
for (const t of e.audioTemplates || []) {
addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags });
addNode(t.id, 'audio_template', t.name, t.engine_type, { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// 5. Pattern templates
for (const t of e.patternTemplates || []) {
addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags });
addNode(t.id, 'pattern_template', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// 6. Sync clocks
for (const c of e.syncClocks || []) {
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags });
addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false, tags: c.tags, icon: c.icon, iconColor: c.icon_color });
}
// 7. Picture sources
for (const s of e.pictureSources || []) {
addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags });
addNode(s.id, 'picture_source', s.name, s.stream_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
}
// 8. Audio sources
for (const s of e.audioSources || []) {
addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags });
addNode(s.id, 'audio_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
}
// 9. Value sources
for (const s of e.valueSources || []) {
addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags });
addNode(s.id, 'value_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
}
// 10. Color strip sources
for (const s of e.colorStripSources || []) {
addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags });
addNode(s.id, 'color_strip_source', s.name, s.source_type, { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
}
// 11. Output targets
for (const t of e.outputTargets || []) {
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags });
addNode(t.id, 'output_target', t.name, t.target_type, { running: t.running || false, tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// 12. Scene presets
for (const s of e.scenePresets || []) {
addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags });
addNode(s.id, 'scene_preset', s.name, '', { tags: s.tags, icon: s.icon, iconColor: s.icon_color });
}
// 13. Automations
for (const a of e.automations || []) {
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags });
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false, tags: a.tags, icon: a.icon, iconColor: a.icon_color });
}
// 14. Color strip processing templates (CSPT)
for (const t of e.csptTemplates || []) {
addNode(t.id, 'cspt', t.name, '', { tags: t.tags });
addNode(t.id, 'cspt', t.name, '', { tags: t.tags, icon: t.icon, iconColor: t.icon_color });
}
// ── Edges ──
@@ -414,7 +443,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id');
}
return { nodes, edges };
return { nodes, edges, brokenRefs };
}
/* ── Port computation ── */
@@ -6,6 +6,7 @@ import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-la
import { EDGE_COLORS } from './graph-edges.ts';
import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.ts';
import { getCardColor, setCardColor } from './card-colors.ts';
import { renderDeviceIconSvg } from './device-icons.ts';
import * as P from './icon-paths.ts';
const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -22,6 +23,8 @@ interface GraphNode {
kind: string;
name: string;
subtype?: string;
icon?: string;
iconColor?: string;
x: number;
y: number;
width: number;
@@ -360,15 +363,29 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
}
}
// Entity icon (right side)
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
if (iconPaths) {
// Entity icon (right side). A custom per-entity icon wins over the
// kind/subtype default (parity with custom node colours); unknown icon ids
// yield '' so we fall back gracefully.
const customIconSvg = node.icon ? renderDeviceIconSvg(node.icon, { size: 16 }) : '';
if (customIconSvg) {
const iconG = svgEl('g', {
class: 'graph-node-icon',
transform: `translate(${width - 28}, ${height / 2 - 8}) scale(0.667)`,
class: 'graph-node-custom-icon',
transform: `translate(${width - 28}, ${height / 2 - 8})`,
});
iconG.innerHTML = iconPaths;
iconG.innerHTML = customIconSvg;
// The rendered SVG strokes with currentColor — tint via `color`.
if (node.iconColor) (iconG as unknown as SVGGElement).style.color = node.iconColor;
g.appendChild(iconG);
} else {
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
if (iconPaths) {
const iconG = svgEl('g', {
class: 'graph-node-icon',
transform: `translate(${width - 28}, ${height / 2 - 8}) scale(0.667)`,
});
iconG.innerHTML = iconPaths;
g.appendChild(iconG);
}
}
// Running dot
@@ -627,6 +644,39 @@ export function markOrphans(group: SVGGElement, nodeMap: Map<string, GraphNode>,
}
}
/**
* Mark nodes that have configuration issues (e.g. broken references, cycles).
* Adds a warning badge anchored to the node's top-left corner with a tooltip
* describing every problem. Call after `renderNodes`.
*/
export function markIssues(group: SVGGElement, issues: Map<string, string[]>): void {
// Clear previous markers so repeated calls don't stack badges.
group.querySelectorAll('.graph-node-issue').forEach(e => e.remove());
group.querySelectorAll('.graph-node.has-issue').forEach(n => n.classList.remove('has-issue'));
for (const [id, msgs] of issues) {
if (!msgs.length) continue;
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
if (!el) continue;
el.classList.add('has-issue');
const badge = svgEl('g', { class: 'graph-node-issue' });
const icon = svgEl('g', { transform: 'translate(2, -9) scale(0.6)' });
icon.innerHTML = P.triangleAlert;
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '2.5');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
badge.appendChild(icon);
const tip = svgEl('title');
tip.textContent = msgs.join('\n');
badge.appendChild(tip);
el.appendChild(badge);
}
}
/**
* Update selection state on nodes.
*/
@@ -5,7 +5,7 @@
import {
_discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache,
csptCache,
csptCache, mqttSourcesCache,
} from '../core/state.ts';
import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost } from '../core/api-client.ts';
@@ -18,6 +18,7 @@ import { runPairingFlow, PairingCancelled } from './pairing-flow.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts';
import { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import type { MQTTSource } from '../types.ts';
class AddDeviceModal extends Modal {
constructor() { super('add-device-modal'); }
@@ -44,6 +45,7 @@ class AddDeviceModal extends Modal {
opcChannel: (document.getElementById('device-opc-channel') as HTMLInputElement)?.value || '0',
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
mqttSource: (document.getElementById('device-mqtt-source') as HTMLSelectElement)?.value || '',
yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50',
@@ -72,6 +74,7 @@ function _buildDeviceTypeItems() {
let _deviceTypeIconSelect: any = null;
let _csptEntitySelect: any = null;
let _mqttSourceEntitySelect: any = null;
function _ensureDeviceTypeIconSelect() {
const sel = document.getElementById('device-type');
@@ -104,6 +107,36 @@ function _ensureCsptEntitySelect() {
}
}
// MQTT broker picker for device_type=mqtt. Empty value = first available broker.
function _ensureMqttSourceSelect() {
const sel = document.getElementById('device-mqtt-source') as HTMLSelectElement | null;
if (!sel) return;
const sources: MQTTSource[] = mqttSourcesCache.data || [];
const current = sel.value;
sel.innerHTML = `<option value="">${t('device.mqtt_source.none')}</option>` +
sources.map((s: MQTTSource) => `<option value="${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`).join('');
sel.value = current;
if (_mqttSourceEntitySelect) _mqttSourceEntitySelect.destroy();
_mqttSourceEntitySelect = new EntitySelect({
target: sel,
getItems: () => (mqttSourcesCache.data || []).map((s: MQTTSource) => ({
value: s.id,
label: s.name,
icon: ICON_PALETTE,
desc: `${s.broker_host}:${s.broker_port}`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('device.mqtt_source.none'),
} as any);
}
function _showMqttSourceField(show: boolean) {
const group = document.getElementById('device-mqtt-source-group') as HTMLElement | null;
if (group) group.style.display = show ? '' : 'none';
if (show) mqttSourcesCache.fetch().then(() => _ensureMqttSourceSelect());
}
/* ── Icon-grid DMX protocol selector ─────────────────────────── */
function _buildDmxProtocolItems() {
@@ -297,6 +330,7 @@ export function onDeviceTypeChanged() {
_showGameSenseFields(false);
_showGroupFields(false);
_showOpcFields(false);
_showMqttSourceField(false);
if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
@@ -314,6 +348,7 @@ export function onDeviceTypeChanged() {
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
_showMqttSourceField(true);
} else if (isMockDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
@@ -920,6 +955,14 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
_updateBleGoveeKeyVisibility();
}
// Prefill MQTT broker (after the source cache loads)
if (isMqttDevice(presetType) && cloneData.mqtt_source_id) {
mqttSourcesCache.fetch().then(() => {
const msEl = document.getElementById('device-mqtt-source') as HTMLSelectElement;
if (msEl) msEl.value = cloneData.mqtt_source_id;
_ensureMqttSourceSelect();
});
}
// Prefill DMX fields
if (isDmxDevice(presetType)) {
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
@@ -1217,6 +1260,10 @@ export async function handleAddDevice(event: any) {
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
if (goveeKey) body.ble_govee_key = goveeKey;
}
if (isMqttDevice(deviceType)) {
const mqttSource = (document.getElementById('device-mqtt-source') as HTMLSelectElement)?.value || '';
if (mqttSource) body.mqtt_source_id = mqttSource;
}
if (isSpiDevice(deviceType)) {
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
@@ -4,7 +4,7 @@
import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
csptCache, mqttSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
@@ -13,18 +13,19 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE, ICON_PALETTE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts';
import type { Device, MQTTSource } from '../types.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { ICON_EDIT } from '../core/icons.ts';
let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null;
let _settingsMqttEntitySelect: any = null;
/* The General Settings modal groups its many conditional fields into
four `.ds-section` panels (Identity / Connection / Hardware / Behavior).
@@ -73,6 +74,29 @@ function _ensureSettingsCsptSelect() {
}
}
// MQTT broker picker for the settings modal (device_type=mqtt). Empty = first available.
function _ensureSettingsMqttSelect(selectedId: string = '') {
const sel = document.getElementById('settings-mqtt-source') as HTMLSelectElement | null;
if (!sel) return;
const sources: MQTTSource[] = mqttSourcesCache.data || [];
sel.innerHTML = `<option value="">${t('device.mqtt_source.none')}</option>` +
sources.map((s: MQTTSource) => `<option value="${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`).join('');
sel.value = selectedId || '';
if (_settingsMqttEntitySelect) _settingsMqttEntitySelect.destroy();
_settingsMqttEntitySelect = new EntitySelect({
target: sel,
getItems: () => (mqttSourcesCache.data || []).map((s: MQTTSource) => ({
value: s.id,
label: s.name,
icon: ICON_PALETTE,
desc: `${s.broker_host}:${s.broker_port}`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('device.mqtt_source.none'),
} as any);
}
class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); }
@@ -103,6 +127,7 @@ class DeviceSettingsModal extends Modal {
goveeMinInterval: (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value || '50',
nanoleafMinInterval: (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value || '100',
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
mqttSource: (document.getElementById('settings-mqtt-source') as HTMLSelectElement | null)?.value || '',
};
}
@@ -439,6 +464,8 @@ export async function showSettings(deviceId: any) {
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
const urlInput = document.getElementById('settings-device-url') as HTMLInputElement;
const mqttSourceGroup = document.getElementById('settings-mqtt-source-group') as HTMLElement | null;
if (mqttSourceGroup) mqttSourceGroup.style.display = 'none';
if (isMock || isWs || isGroup) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
@@ -458,6 +485,7 @@ export async function showSettings(deviceId: any) {
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
if (mqttSourceGroup) mqttSourceGroup.style.display = '';
} else if (isOpenrgbDevice(device.device_type)) {
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
@@ -805,6 +833,13 @@ 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 || '';
// MQTT broker selector (mqtt devices only) — populated before snapshot
// so the dirty-check baseline matches the current broker.
if (isMqtt) {
await mqttSourcesCache.fetch();
_ensureSettingsMqttSelect(device.mqtt_source_id || '');
}
_updateSettingsSectionVisibility();
settingsModal.snapshot();
settingsModal.open();
@@ -819,7 +854,7 @@ export async function showSettings(deviceId: any) {
}
export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); destroyDdpColorOrderIconSelect('settings-ddp-color-order'); destroyDmxProtocolIconSelect('settings-dmx-protocol'); settingsModal.forceClose(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } if (_settingsMqttEntitySelect) { _settingsMqttEntitySelect.destroy(); _settingsMqttEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); destroyDdpColorOrderIconSelect('settings-ddp-color-order'); destroyDmxProtocolIconSelect('settings-dmx-protocol'); settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
@@ -908,6 +943,9 @@ export async function saveDeviceSettings() {
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
body.ble_govee_key = goveeKey;
}
if (isMqttDevice(settingsModal.deviceType)) {
body.mqtt_source_id = (document.getElementById('settings-mqtt-source') as HTMLSelectElement | null)?.value || '';
}
if (isGroup) {
const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>;
body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
@@ -4,7 +4,8 @@
import { GraphCanvas } from '../core/graph-canvas.ts';
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.ts';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.ts';
import type { BrokenRef } from '../core/graph-layout.ts';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, markIssues, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.ts';
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.ts';
import {
devicesCache, captureTemplatesCache, ppTemplatesCache,
@@ -14,13 +15,14 @@ import {
automationsCacheObj, csptCache,
} from '../core/state.ts';
import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts';
import { apiGet } from '../core/api-client.ts';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { t } from '../core/i18n.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
import { showTypePicker } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { readJson, isObject, isString, isNumber } from '../core/storage.ts';
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
import { logError } from '../core/log.ts';
// Local type guard for AnchoredRect — `JSON.parse` returns unknown and the
@@ -122,8 +124,33 @@ let _dragState: DragState | null = null;
let _justDragged = false;
let _dragListenersAdded = false;
// Manual position overrides (persisted in memory; cleared on relayout)
let _manualPositions: Map<string, { x: number; y: number }> = new Map();
// Manual position overrides persisted to localStorage so a hand-arranged
// layout survives page reloads; cleared explicitly on relayout.
const _POS_KEY = 'graph_node_positions';
function _isPositionMap(v: unknown): v is Record<string, { x: number; y: number }> {
if (!isObject(v)) return false;
return Object.values(v).every(p => isObject(p) && isNumber((p as any).x) && isNumber((p as any).y));
}
function _loadManualPositions(): Map<string, { x: number; y: number }> {
const obj = readJson(_POS_KEY, _isPositionMap);
const map = new Map<string, { x: number; y: number }>();
if (obj) for (const [id, p] of Object.entries(obj)) map.set(id, { x: p.x, y: p.y });
return map;
}
function _saveManualPositions(): void {
const obj: Record<string, { x: number; y: number }> = {};
for (const [id, p] of _manualPositions) obj[id] = p;
writeJson(_POS_KEY, obj);
}
let _manualPositions: Map<string, { x: number; y: number }> = _loadManualPositions();
// Node IDs that currently have a configuration issue (broken ref / cycle).
let _issueIds: Set<string> = new Set();
// Dangling references discovered during the last layout build.
let _brokenRefs: BrokenRef[] = [];
// Raw fetched entities by id — lets drop-resolution check which bindable slots
// a target actually has (subtype-safe), without re-fetching.
let _entitiesById: Map<string, any> = new Map();
// Rubber-band selection state
interface RubberBandState { startGraph: { x: number; y: number }; startClient: { x: number; y: number }; active: boolean; }
@@ -333,7 +360,12 @@ export async function loadGraphEditor(): Promise<void> {
try {
const entities = await _fetchAllEntities();
const { nodes, edges, bounds } = await computeLayout(entities);
// Index raw entities by id for subtype-safe bindable-slot resolution.
_entitiesById = new Map();
for (const arr of Object.values(entities)) {
if (Array.isArray(arr)) for (const ent of arr) if (ent && ent.id) _entitiesById.set(ent.id, ent);
}
const { nodes, edges, bounds, brokenRefs } = await computeLayout(entities);
// Apply manual position overrides from previous drag operations
_applyManualPositions(nodes, edges);
@@ -341,6 +373,7 @@ export async function loadGraphEditor(): Promise<void> {
computePorts(nodes as any, edges);
_nodeMap = nodes as any;
_edges = edges;
_brokenRefs = brokenRefs;
_bounds = _calcBounds(nodes);
_renderGraph(container);
} finally {
@@ -661,9 +694,147 @@ export async function graphRelayout(): Promise<void> {
if (!ok) return;
}
_manualPositions.clear();
_saveManualPositions();
await loadGraphEditor();
}
/* ── Health overlay (broken references + dependency cycles) ── */
/** Humanize a reference field name for display (e.g. `capture_template_id` → `capture template`). */
function _humanField(field: string): string {
return field.replace(/_id$/, '').replace(/\./g, ' ').replace(/_/g, ' ').trim();
}
/**
* Detect every node that participates in a directed dependency cycle.
* Iterative DFS with an explicit path stack (no deep recursion).
*/
function _detectCycles(nodeMap: Map<string, any>, edges: any[]): Set<string> {
const adj = new Map<string, string[]>();
for (const e of edges) {
if (!adj.has(e.from)) adj.set(e.from, []);
adj.get(e.from)!.push(e.to);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const inCycle = new Set<string>();
for (const start of nodeMap.keys()) {
if (color.get(start)) continue; // already GRAY/BLACK
const stack: Array<{ id: string; i: number }> = [{ id: start, i: 0 }];
const path: string[] = [start];
color.set(start, GRAY);
while (stack.length) {
const frame = stack[stack.length - 1];
const neighbors = adj.get(frame.id) || [];
if (frame.i < neighbors.length) {
const v = neighbors[frame.i++];
const c = color.get(v) ?? WHITE;
if (c === GRAY) {
// Back edge → mark the whole cycle from v to the current node.
const idx = path.indexOf(v);
if (idx >= 0) for (let k = idx; k < path.length; k++) inCycle.add(path[k]);
} else if (c === WHITE) {
color.set(v, GRAY);
path.push(v);
stack.push({ id: v, i: 0 });
}
} else {
color.set(frame.id, BLACK);
path.pop();
stack.pop();
}
}
}
return inCycle;
}
/** Build the per-node issue map, render badges, and update the toolbar button. */
function _computeAndMarkIssues(nodeGroup: SVGGElement): void {
const issues = new Map<string, string[]>();
const add = (id: string, msg: string): void => {
const list = issues.get(id);
if (list) list.push(msg); else issues.set(id, [msg]);
};
// Dangling references: the referrer still exists but its target is gone.
for (const br of _brokenRefs) {
add(br.by, t('graph.issue.broken_ref', { field: _humanField(br.field) }));
}
// Dependency cycles.
if (_nodeMap && _edges) {
for (const id of _detectCycles(_nodeMap, _edges)) add(id, t('graph.issue.cycle'));
}
_issueIds = new Set(issues.keys());
markIssues(nodeGroup, issues);
_updateIssuesButton();
}
function _updateIssuesButton(): void {
const btn = document.getElementById('graph-issues-btn');
if (!btn) return;
const count = _issueIds.size;
const countEl = btn.querySelector('.graph-issues-count');
if (countEl) countEl.textContent = count > 0 ? String(count) : '';
(btn as HTMLElement).style.display = count > 0 ? '' : 'none';
}
/**
* Export the full wiring topology (nodes + edges + validation report) as a
* downloadable JSON file. This is the read-only half of "wiring blueprints":
* a shareable, inspectable snapshot. Re-importing/instantiating a blueprint
* (with id remapping) is a separate, larger feature.
*/
export async function graphExportTopology(): Promise<void> {
try {
const topo = await apiGet<unknown>('/graph');
const blob = new Blob([JSON.stringify(topo, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ledgrab-graph-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(t('graph.export_done'), 'success');
} catch (e) {
showToast(e instanceof Error ? e.message : t('graph.export_failed'), 'error');
}
}
/** Frame and highlight all nodes flagged with configuration issues. */
export function graphShowIssues(): void {
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
showToast(t('graph.issues_none'), 'info');
return;
}
const ng = document.querySelector('.graph-nodes') as SVGGElement | null;
const eg = document.querySelector('.graph-edges') as SVGGElement | null;
_selectedIds = new Set(_issueIds);
if (ng) {
updateSelection(ng, _selectedIds);
ng.querySelectorAll('.graph-node').forEach((n: any) => {
n.style.opacity = _issueIds.has(n.getAttribute('data-id')) ? '1' : '0.2';
});
}
if (eg) clearEdgeHighlights(eg);
// Fit the viewport to the bounding box of the flagged nodes.
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of _issueIds) {
const n = _nodeMap.get(id);
if (!n) continue;
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width); maxY = Math.max(maxY, n.y + n.height);
}
if (minX !== Infinity) {
_canvas.fitAll({ x: minX, y: minY, width: maxX - minX, height: maxY - minY }, true);
}
}
// Entity kind → window function to open add/create modal + icon path
const _ico = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const _w = window as any;
@@ -693,6 +864,25 @@ const ALL_CACHES = [
automationsCacheObj, csptCache,
];
// entity kind → its DataCache, so a kind-scoped watcher (create-and-connect)
// only reacts to new entities of the expected kind, never an unrelated one.
const KIND_CACHE: Record<string, { data?: any[]; subscribe(fn: (d: any) => void): void; unsubscribe(fn: (d: any) => void): void }> = {
device: devicesCache,
capture_template: captureTemplatesCache,
pp_template: ppTemplatesCache,
audio_template: audioTemplatesCache,
pattern_template: patternTemplatesCache,
picture_source: streamsCache,
audio_source: audioSourcesCache,
value_source: valueSourcesCache,
color_strip_source: colorStripSourcesCache,
sync_clock: syncClocksCache,
output_target: outputTargetsCache,
scene_preset: scenePresetsCache,
automation: automationsCacheObj,
cspt: csptCache,
};
export function graphAddEntity(): void {
const items = ADD_ENTITY_MAP.map(item => ({
value: item.kind,
@@ -712,16 +902,62 @@ export function graphAddEntity(): void {
});
}
/**
* Drag-from-port onto empty canvas → offer to create a compatible consumer
* entity and wire it to the source in one gesture (the classic node-editor
* "drag out a new node" flow).
*/
function _promptCreateAndConnect(sourceKind: string, sourceId: string): void {
// Kinds that can consume this source (non-nested) and have an add action.
const kinds = [...new Set(getCompatibleInputs(sourceKind).map(c => c.targetKind))]
.filter(k => ADD_ENTITY_MAP.some(e => e.kind === k));
if (kinds.length === 0) {
showToast(t('graph.no_compatible_connection'), 'info');
return;
}
const items = kinds.map(k => {
const entry = ADD_ENTITY_MAP.find(e => e.kind === k)!;
return { value: k, icon: entry.icon, label: ENTITY_LABELS[k] || k.replace(/_/g, ' ') };
});
showTypePicker({
title: t('graph.create_and_connect'),
items,
onPick: (kind) => _createAndConnect(kind, sourceKind, sourceId),
});
}
/** Open the add-entity modal for `targetKind`, then wire the new entity to `sourceId`. */
function _createAndConnect(targetKind: string, sourceKind: string, sourceId: string): void {
const entry = ADD_ENTITY_MAP.find(e => e.kind === targetKind);
if (!entry) return;
_watchForNewEntity((newId) => {
const matches = findConnection(targetKind, sourceKind);
if (matches.length === 1) {
_doConnect(newId, targetKind, matches[0].field, sourceId);
} else if (matches.length > 1) {
_promptConnectionField(matches, newId, targetKind, sourceId);
} else {
// No resolvable field — just refresh so the new node appears.
loadGraphEditor();
}
}, targetKind);
entry.fn();
}
// Watch for new entity creation after add-entity menu action
let _entityWatchCleanup: (() => void) | null = null;
function _watchForNewEntity(): void {
function _watchForNewEntity(onNew?: (newId: string) => void, expectKind?: string): void {
// Cleanup any previous watcher
if (_entityWatchCleanup) _entityWatchCleanup();
// Scope to the expected kind's cache when given (create-and-connect), so the
// callback never fires for an unrelated entity that happens to appear first.
const caches = (expectKind && KIND_CACHE[expectKind]) ? [KIND_CACHE[expectKind]] : ALL_CACHES;
// Snapshot all current IDs
const knownIds = new Set<string>();
for (const cache of ALL_CACHES) {
for (const cache of caches) {
for (const item of (cache.data || [])) {
if (item.id) knownIds.add(item.id);
}
@@ -731,9 +967,12 @@ function _watchForNewEntity(): void {
if (!Array.isArray(data)) return;
for (const item of data) {
if (item.id && !knownIds.has(item.id)) {
// Found a new entity — reload graph and zoom to it
// Found a new entity.
const newId = item.id;
cleanup();
// Custom handler (e.g. create-and-connect) takes over.
if (onNew) { onNew(newId); return; }
// Default: reload graph and zoom to the new node.
loadGraphEditor().then(() => {
const node = _nodeMap?.get(newId);
if (node && _canvas) {
@@ -751,14 +990,14 @@ function _watchForNewEntity(): void {
}
};
for (const cache of ALL_CACHES) cache.subscribe(handler);
for (const cache of caches) cache.subscribe(handler);
// Auto-cleanup after 2 minutes (user might cancel the modal)
const timeout = setTimeout(cleanup, 120_000);
function cleanup() {
clearTimeout(timeout);
for (const cache of ALL_CACHES) cache.unsubscribe(handler);
for (const cache of caches) cache.unsubscribe(handler);
_entityWatchCleanup = null;
}
@@ -829,6 +1068,9 @@ function _renderGraph(container: HTMLElement): void {
});
markOrphans(nodeGroup, _nodeMap!, _edges!);
// Health overlay: flag dangling references and dependency cycles.
_computeAndMarkIssues(nodeGroup);
// Animated flow dots for running nodes
const runningIds = new Set<string>();
for (const node of _nodeMap!.values()) {
@@ -845,6 +1087,8 @@ function _renderGraph(container: HTMLElement): void {
_canvas.onZoomChange = (z) => {
const label = container.querySelector('.graph-zoom-label');
if (label) label.textContent = `${Math.round(z * 100)}%`;
// Reveal edge field labels once zoomed in enough to read them.
edgeGroup.classList.toggle('show-labels', z >= 0.9);
};
_canvas.onViewChange = (vp) => {
@@ -993,6 +1237,9 @@ function _renderGraph(container: HTMLElement): void {
container.focus();
// Re-focus when clicking inside the graph
svgEl.addEventListener('pointerdown', () => container.focus());
// The toolbar markup hardcodes `disabled` on undo/redo; re-sync with the
// live stacks since this render may follow an undoable action.
_updateUndoRedoButtons();
_initialized = true;
}
@@ -1048,6 +1295,10 @@ function _graphHTML(): string {
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}" data-collapse>
<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="btn-icon graph-issues-btn" id="graph-issues-btn" onclick="graphShowIssues()" title="${t('graph.issues')}" style="display:none" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<span class="graph-issues-count"></span>
</button>
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)" data-collapse>
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
@@ -1098,6 +1349,10 @@ function _graphHTML(): string {
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
<span>${t('graph.fullscreen')}</span>
</button>
<button onclick="graphExportTopology(); closeToolbarOverflow()">
<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"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>${t('graph.export')}</span>
</button>
<div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
<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>
@@ -1525,7 +1780,8 @@ function _onEditNode(node: any) {
fnMap[node.kind]?.();
}
function _onDeleteNode(node: any) {
/** Dispatch the per-entity delete (each shows its own confirm + handles API/cache). */
function _deleteNodeRaw(node: any): void {
const fnMap: any = {
device: () => _w.removeDevice?.(node.id),
capture_template: () => _w.deleteTemplate?.(node.id),
@@ -1545,6 +1801,30 @@ function _onDeleteNode(node: any) {
fnMap[node.kind]?.();
}
// Guards the await gap (dependents fetch + confirm) against a double-fire from
// rapid Delete presses or trash-button + Delete on the same node.
const _deletingIds = new Set<string>();
/**
* Single-node delete from the graph: first warn if other entities reference
* this one (their wiring would break), then hand off to the per-entity delete.
*/
async function _onDeleteNode(node: any): Promise<void> {
if (_deletingIds.has(node.id)) return;
_deletingIds.add(node.id);
try {
const deps = await getDependents(node.kind, node.id);
if (deps.length > 0) {
const names = deps.slice(0, 5).map(d => d.name).join(', ') + (deps.length > 5 ? ', …' : '');
const ok = await showConfirm(t('graph.delete_with_dependents_confirm', { count: deps.length, names }));
if (!ok) return;
}
_deleteNodeRaw(node);
} finally {
_deletingIds.delete(node.id);
}
}
async function _bulkDeleteSelected(): Promise<void> {
const count = _selectedIds.size;
if (count < 2) return;
@@ -1552,9 +1832,11 @@ async function _bulkDeleteSelected(): Promise<void> {
(t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count))
);
if (!ok) return;
// Bulk uses the raw delegate — the single bulk confirm covers the batch, and
// per-node dependents prompts would be a dialog storm.
for (const id of _selectedIds) {
const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node);
if (node) _deleteNodeRaw(node);
}
_selectedIds.clear();
}
@@ -1991,17 +2273,37 @@ function _onDragPointerUp(): void {
_justDragged = true;
requestAnimationFrame(() => { _justDragged = false; });
const moved: Array<{ id: string; oldX: number; oldY: number; newX: number; newY: number }> = [];
if (_dragState.multi) {
_dragState.nodes.forEach(n => {
if (n.el) n.el.classList.remove('dragging');
const node = _nodeMap!.get(n.id);
if (node) _manualPositions.set(n.id, { x: node.x, y: node.y });
if (node) {
_manualPositions.set(n.id, { x: node.x, y: node.y });
moved.push({ id: n.id, oldX: n.startX, oldY: n.startY, newX: node.x, newY: node.y });
}
});
} else {
const ds = _dragState as DragStateSingle;
ds.el.classList.remove('dragging');
const node = _nodeMap!.get(ds.nodeId);
if (node) _manualPositions.set(ds.nodeId, { x: node.x, y: node.y });
if (node) {
_manualPositions.set(ds.nodeId, { x: node.x, y: node.y });
moved.push({ id: ds.nodeId, oldX: ds.startNode.x, oldY: ds.startNode.y, newX: node.x, newY: node.y });
}
}
// Persist the hand-arranged layout so it survives page reloads.
_saveManualPositions();
// Record an undoable move (skip no-op drags below the dead zone).
if (moved.some(m => m.oldX !== m.newX || m.oldY !== m.newY)) {
pushUndoAction({
label: t('graph.action.move'),
undo: async () => { for (const m of moved) _manualPositions.set(m.id, { x: m.oldX, y: m.oldY }); _saveManualPositions(); },
redo: async () => { for (const m of moved) _manualPositions.set(m.id, { x: m.newX, y: m.newY }); _saveManualPositions(); },
});
}
_bounds = _calcBounds(_nodeMap);
@@ -2201,11 +2503,26 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
p.classList.add('graph-port-incompatible');
}
});
// Also highlight whole compatible target NODES, so a slot with no
// existing edge (and therefore no input port yet) is still droppable —
// the user can drop anywhere on the node body to wire an empty slot.
const compatibleKinds = new Set(compatible.map(c => c.targetKind));
nodeGroup.querySelectorAll('.graph-node').forEach(n => {
const nKind = n.getAttribute('data-kind');
const nId = n.getAttribute('data-id');
if (nId !== sourceNodeId && nKind && compatibleKinds.has(nKind)) {
n.classList.add('graph-node-compatible');
}
});
}, true); // capture phase to beat node drag
if (!_connectListenersAdded) {
window.addEventListener('pointermove', _onConnectPointerMove);
window.addEventListener('pointerup', _onConnectPointerUp);
// pointercancel (touch interruption, capture loss) must also tear down
// the drag — otherwise the temp edge, node highlights and blockPan stick.
window.addEventListener('pointercancel', _onConnectPointerUp);
_connectListenersAdded = true;
}
}
@@ -2228,12 +2545,20 @@ function _onConnectPointerMove(e: PointerEvent): void {
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
if (port) port.classList.add('graph-port-drop-target');
// Highlight the compatible node under the cursor for drop-on-node wiring
// (only when not already hovering a specific port).
svgEl.querySelectorAll('.graph-node-drop-target').forEach(n => n.classList.remove('graph-node-drop-target'));
if (!port) {
const nodeUnder = elem?.closest?.('.graph-node-compatible');
if (nodeUnder) nodeUnder.classList.add('graph-node-drop-target');
}
}
function _onConnectPointerUp(e: PointerEvent): void {
if (!_connectState) return;
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
const { sourceNodeId, sourceKind, startX, startY, dragPath } = _connectState;
// Clean up drag edge
dragPath.remove();
@@ -2241,48 +2566,132 @@ function _onConnectPointerUp(e: PointerEvent): void {
if (svgEl) svgEl.classList.remove('connecting');
if (_canvas) _canvas.blockPan = false;
// Clean up port highlights
// Clean up port + node highlights
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
if (nodeGroup) {
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
});
nodeGroup.querySelectorAll('.graph-node-compatible, .graph-node-drop-target').forEach(n => {
n.classList.remove('graph-node-compatible', 'graph-node-drop-target');
});
}
// Check if dropped on a compatible input port
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
// Dropped on a specific input port — resolve by that port's edge type.
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
const matches = findConnection(targetKind, sourceKind, targetPortType);
const matches = _availableMatches(findConnection(targetKind, sourceKind, targetPortType), targetNodeId);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
// Resolve by source kind
const exact = matches.find(m => m.sourceKind === sourceKind);
if (exact) {
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
// Genuinely ambiguous: the same source kind feeds two distinct
// fields (e.g. an automation's activation vs. deactivation scene).
_promptConnectionField(matches, targetNodeId, targetKind, sourceNodeId);
}
}
} else {
// Dropped on the node body (or an empty slot that has no port yet):
// resolve every connectable field for this source→target pair. This is
// what makes unconnected slots wireable from the graph.
const targetNode = elem?.closest?.('.graph-node');
if (targetNode) {
const targetNodeId = targetNode.getAttribute('data-id') ?? '';
const targetKind = targetNode.getAttribute('data-kind') ?? '';
if (targetNodeId && targetNodeId !== sourceNodeId) {
const matches = _availableMatches(findConnection(targetKind, sourceKind), targetNodeId);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
_promptConnectionField(matches, targetNodeId, targetKind, sourceNodeId);
} else {
showToast(t('graph.no_compatible_connection'), 'info');
}
}
} else if (_canvas) {
// Dropped on empty canvas — offer to create a compatible consumer
// and wire it (skip when the gesture was effectively a click).
const CREATE_CONNECT_MIN_DRAG = 40; // graph units
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
if (Math.hypot(gp.x - startX, gp.y - startY) > CREATE_CONNECT_MIN_DRAG) {
_promptCreateAndConnect(sourceKind, sourceNodeId);
}
}
}
_connectState = null;
}
/**
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
* — e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
* `smoothing`. Non-bindable matches always pass through.
*/
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
const ent = _entitiesById.get(targetId);
return matches.filter(m => {
if (!m.bindable || !ent) return true;
return m.field.split('.')[0] in ent;
});
}
/** Ask the user which field to wire when a source maps to multiple target fields. */
function _promptConnectionField(
matches: Array<{ field: string }>,
targetNodeId: string,
targetKind: string,
sourceNodeId: string,
): void {
showTypePicker({
title: t('graph.choose_connection'),
items: matches.map(m => ({ value: m.field, icon: _ico(P.link), label: _humanField(m.field) })),
onPick: (field) => { _doConnect(targetNodeId, targetKind, field, sourceNodeId); },
});
}
/** The id currently wired into (targetId, field), or '' if the slot is empty. */
function _currentSourceFor(targetId: string, field: string): string {
const edge = _edges?.find(e => e.to === targetId && e.field === field);
return edge ? edge.from : '';
}
async function _doConnect(targetId: string, targetKind: string, field: string, sourceId: string): Promise<void> {
const prevSourceId = _currentSourceFor(targetId, field);
if (prevSourceId === sourceId) return; // dropped onto the existing connection — no-op
// Pre-flight validation (existence + source kind + no dependency cycle).
const v = await validateConnection(targetKind, targetId, field, sourceId);
if (!v.ok) {
showToast(v.error || t('graph.connection_failed'), 'error');
return;
}
// Confirm before overwriting an already-occupied slot.
if (prevSourceId) {
const confirmed = await showConfirm(t('graph.replace_connection_confirm'));
if (!confirmed) return;
}
const ok = await updateConnection(targetId, targetKind, field, sourceId);
if (ok) {
showToast(t('graph.connection_updated') || 'Connection updated', 'success');
// Record an undoable action that restores the previous slot occupant.
// The inverse ops throw on failure so `_undo`/`_redo` can keep the
// action on its stack instead of silently desyncing (updateConnection
// returns false rather than throwing on API error).
pushUndoAction({
label: t('graph.action.connect'),
undo: async () => { if (!(await updateConnection(targetId, targetKind, field, prevSourceId))) throw new Error(t('graph.connection_failed')); },
redo: async () => { if (!(await updateConnection(targetId, targetKind, field, sourceId))) throw new Error(t('graph.connection_failed')); },
});
showToast(t('graph.connection_updated'), 'success');
await loadGraphEditor();
} else {
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
showToast(t('graph.connection_failed'), 'error');
}
}
@@ -2311,31 +2720,35 @@ export async function graphUndo(): Promise<void> { await _undo(); }
export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo'), 'info'); return; }
const action = _undoStack.pop()!;
try {
await action.undo();
_redoStack.push(action);
showToast(t('graph.undone') || `Undone: ${action.label}`, 'info');
showToast(t('graph.undone'), 'info');
_updateUndoRedoButtons();
await loadGraphEditor();
} catch (e) {
showToast(e.message, 'error');
// The inverse op failed — keep the action on the undo stack so the
// user can retry, and surface the error instead of a false success.
_undoStack.push(action);
showToast(e instanceof Error ? e.message : String(e), 'error');
_updateUndoRedoButtons();
}
}
async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo'), 'info'); return; }
const action = _redoStack.pop()!;
try {
await action.redo();
_undoStack.push(action);
showToast(t('graph.redone') || `Redone: ${action.label}`, 'info');
showToast(t('graph.redone'), 'info');
_updateUndoRedoButtons();
await loadGraphEditor();
} catch (e) {
showToast(e.message, 'error');
_redoStack.push(action);
showToast(e instanceof Error ? e.message : String(e), 'error');
_updateUndoRedoButtons();
}
}
@@ -2402,6 +2815,28 @@ export function toggleGraphHelp(): void {
/* ── Edge context menu (right-click to detach) ── */
/**
* Detach a connection and record an undoable action that restores it.
* @param prevSourceId the id previously wired into the slot (for undo)
*/
async function _doDetach(to: string, targetKind: string, field: string, prevSourceId: string): Promise<boolean> {
const ok = await detachConnection(to, targetKind, field);
if (ok) {
if (prevSourceId) {
pushUndoAction({
label: t('graph.action.disconnect'),
undo: async () => { if (!(await updateConnection(to, targetKind, field, prevSourceId))) throw new Error(t('graph.connection_failed')); },
redo: async () => { if (!(await detachConnection(to, targetKind, field))) throw new Error(t('graph.disconnect_failed')); },
});
}
showToast(t('graph.connection_removed'), 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed'), 'error');
}
return ok;
}
function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
_dismissEdgeContextMenu();
@@ -2409,6 +2844,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to') ?? '';
const fromId = edgePath.getAttribute('data-from') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
@@ -2419,19 +2855,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item danger';
btn.textContent = t('graph.disconnect') || 'Disconnect';
btn.textContent = t('graph.disconnect');
btn.addEventListener('click', async () => {
try {
_dismissEdgeContextMenu();
const ok = await detachConnection(toId, toNode.kind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
await _doDetach(toId, toNode.kind, field, fromId);
} catch (err) {
showToast(`${t('graph.disconnect_failed') || 'Failed to disconnect'}: ${err instanceof Error ? err.message : String(err)}`, 'error');
showToast(`${t('graph.disconnect_failed')}: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
});
menu.appendChild(btn);
@@ -2449,16 +2879,9 @@ function _dismissEdgeContextMenu(): void {
async function _detachSelectedEdge(): Promise<void> {
if (!_selectedEdge) return;
const { to, field, targetKind } = _selectedEdge;
const { from, to, field, targetKind } = _selectedEdge;
_selectedEdge = null;
const ok = await detachConnection(to, targetKind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
await _doDetach(to, targetKind, field, from);
}
/* ── Node hover FPS tooltip ── */
+2
View File
@@ -377,6 +377,8 @@ startTargetOverlay: (...args: any[]) => any;
graphZoomIn: (...args: any[]) => any;
graphZoomOut: (...args: any[]) => any;
graphRelayout: (...args: any[]) => any;
graphShowIssues: (...args: any[]) => any;
graphExportTopology: (...args: any[]) => any;
graphToggleFullscreen: (...args: any[]) => any;
graphAddEntity: (...args: any[]) => any;
+20
View File
@@ -362,6 +362,9 @@
"device.mqtt_topic": "MQTT Topic:",
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
"device.mqtt_source": "MQTT Broker:",
"device.mqtt_source.hint": "Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.",
"device.mqtt_source.none": "— First available broker",
"device.ws_url": "Connection URL:",
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
"device.openrgb.url": "OpenRGB URL:",
@@ -2582,6 +2585,23 @@
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Errors",
"graph.tooltip.uptime": "Uptime",
"graph.undone": "Undone",
"graph.redone": "Redone",
"graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node",
"graph.choose_connection": "Choose connection",
"graph.issues": "Issues",
"graph.issues_none": "No issues found",
"graph.issue.broken_ref": "Broken reference: {field}",
"graph.issue.cycle": "Part of a dependency cycle",
"graph.replace_connection_confirm": "Replace the existing connection?",
"graph.no_compatible_connection": "No compatible connection between these entities",
"graph.create_and_connect": "Create & connect…",
"graph.export": "Export graph (JSON)",
"graph.export_done": "Graph exported",
"graph.export_failed": "Failed to export graph",
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
"automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled",
"scene_preset.activated": "Preset activated",
+20
View File
@@ -417,6 +417,9 @@
"device.mqtt_topic": "MQTT Топик:",
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
"device.mqtt_source": "MQTT Брокер:",
"device.mqtt_source.hint": "К какому MQTT-брокеру публикует это устройство. Брокеры настраиваются в разделе Интеграции → Источники MQTT. Оставьте пустым, чтобы использовать первый доступный брокер.",
"device.mqtt_source.none": "— Первый доступный брокер",
"device.ws_url": "URL подключения:",
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
"device.openrgb.url": "OpenRGB URL:",
@@ -2264,6 +2267,23 @@
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Ошибки",
"graph.tooltip.uptime": "Время работы",
"graph.undone": "Отменено",
"graph.redone": "Повторено",
"graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел",
"graph.choose_connection": "Выберите соединение",
"graph.issues": "Проблемы",
"graph.issues_none": "Проблем не найдено",
"graph.issue.broken_ref": "Битая ссылка: {field}",
"graph.issue.cycle": "Входит в цикл зависимостей",
"graph.replace_connection_confirm": "Заменить существующее соединение?",
"graph.no_compatible_connection": "Нет совместимого соединения между этими объектами",
"graph.create_and_connect": "Создать и соединить…",
"graph.export": "Экспорт графа (JSON)",
"graph.export_done": "Граф экспортирован",
"graph.export_failed": "Не удалось экспортировать граф",
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
"automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена",
"scene_preset.activated": "Пресет активирован",
+20
View File
@@ -415,6 +415,9 @@
"device.mqtt_topic": "MQTT 主题:",
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
"device.mqtt_source": "MQTT 代理:",
"device.mqtt_source.hint": "此设备发布到哪个 MQTT 代理。在集成 → MQTT 源中管理代理。留空则使用第一个可用代理。",
"device.mqtt_source.none": "— 第一个可用代理",
"device.ws_url": "连接 URL",
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
"device.openrgb.url": "OpenRGB URL",
@@ -2260,6 +2263,23 @@
"graph.tooltip.fps": "帧率",
"graph.tooltip.errors": "错误",
"graph.tooltip.uptime": "运行时间",
"graph.undone": "已撤销",
"graph.redone": "已重做",
"graph.action.connect": "连接",
"graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点",
"graph.choose_connection": "选择连接",
"graph.issues": "问题",
"graph.issues_none": "未发现问题",
"graph.issue.broken_ref": "无效引用:{field}",
"graph.issue.cycle": "属于依赖循环",
"graph.replace_connection_confirm": "替换现有连接?",
"graph.no_compatible_connection": "这些实体之间没有兼容的连接",
"graph.create_and_connect": "创建并连接…",
"graph.export": "导出图谱 (JSON)",
"graph.export_done": "图谱已导出",
"graph.export_failed": "导出图谱失败",
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
"automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用",
"scene_preset.activated": "预设已激活",
+223 -10
View File
@@ -4,20 +4,233 @@
* Strategy:
* - Static assets (/static/): stale-while-revalidate
* - API / config requests: network-only (device control must be live)
* - Navigation: network-first with offline fallback
* - Navigation: network-first with branded offline fallback
*/
const CACHE_NAME = 'ledgrab-v34';
const CACHE_NAME = 'ledgrab-v35';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
// The Orbitron brand font is precached so the offline page renders on-brand
// even on a device that hasn't warmed the font cache yet.
const PRECACHE_URLS = [
'/static/dist/app.bundle.css',
'/static/dist/app.bundle.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/static/fonts/orbitron-700-latin.woff2',
];
// Branded offline fallback shown when a navigation can't reach the server.
// Self-contained (no CDN, no app CSS) so it renders with zero live network.
// Mirrors the "Lumenworks" console aesthetic: pure-black panel, Orbitron brand
// mark, channel-coral for the offline/alarm state, signal-green for restore.
// A background probe self-heals the page — it reloads the instant the server
// answers again, so a restarting server no longer leaves a dead-end screen.
const OFFLINE_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>LED Grab — Signal Lost</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#000;--line:#1c2027;
--ink:#eef2f7;--dim:#aeb7c4;--mute:#6b7480;
--coral:#ff5e5e;
--signal:#4caf50;--signal-hi:#6fd173;
--glow-coral:0 0 18px rgba(255,94,94,.55);
--glow-signal:0 0 10px rgba(76,175,80,.8);
}
@media (prefers-color-scheme: light){
:root{
--bg:#f6f8fb;--line:#dee3ea;
--ink:#0f1419;--dim:#41505f;--mute:#7b8694;
--coral:#d8392e;
--signal:#2e7d32;--signal-hi:#3d8b40;
--glow-coral:0 0 16px rgba(216,57,46,.30);
--glow-signal:0 0 10px rgba(46,125,50,.5);
}
}
@font-face{
font-family:'Orbitron';font-style:normal;font-weight:700;font-display:swap;
src:url('/static/fonts/orbitron-700-latin.woff2') format('woff2');
}
html,body{height:100%}
body{
background:var(--bg);color:var(--ink);
font-family:'Manrope','Segoe UI',system-ui,-apple-system,BlinkMacSystemFont,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
display:grid;place-items:center;min-height:100dvh;position:relative;overflow:hidden;
padding:calc(28px + env(safe-area-inset-top)) 24px calc(28px + env(safe-area-inset-bottom));
}
/* atmosphere: coral alarm vignette + faint signal floor */
body::before{
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
background:
radial-gradient(125% 80% at 50% -12%, rgba(255,94,94,.11), transparent 60%),
radial-gradient(100% 55% at 50% 118%, rgba(0,216,255,.045), transparent 60%);
}
/* fine equipment-panel scanlines */
body::after{
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.5;
mix-blend-mode:screen;
background:repeating-linear-gradient(0deg, rgba(255,255,255,.018) 0 1px, transparent 1px 3px);
}
@media (prefers-color-scheme: light){
body::after{opacity:.4;mix-blend-mode:multiply;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.02) 0 1px, transparent 1px 3px)}
}
.panel{position:relative;z-index:1;width:min(460px,100%);
display:flex;flex-direction:column;align-items:center;text-align:center}
.panel>*{opacity:0;transform:translateY(10px);animation:rise .6s cubic-bezier(.16,1,.3,1) forwards}
.brand{animation-delay:.05s}.strip-wrap{animation-delay:.14s}.chip{animation-delay:.22s}
.headline{animation-delay:.30s}.copy{animation-delay:.38s}.btn{animation-delay:.46s}
.telemetry{animation-delay:.54s}.foot{animation-delay:.62s}
@keyframes rise{to{opacity:1;transform:none}}
.brand{display:flex;align-items:center;gap:11px;margin-bottom:32px}
.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--coral);
box-shadow:var(--glow-coral);animation:beat 1.6s ease-in-out infinite}
.brand-name{font-family:'Orbitron','Segoe UI',sans-serif;font-weight:700;
font-size:.95rem;letter-spacing:.34em;text-indent:.34em;color:var(--ink)}
@keyframes beat{0%,100%{opacity:1}50%{opacity:.28}}
.strip-wrap{width:100%;margin-bottom:28px}
.strip{position:relative;display:flex;gap:7px;padding:15px 12px;overflow:hidden;
border:1px solid var(--line);border-radius:13px;
background:linear-gradient(180deg, rgba(255,255,255,.025), transparent)}
.strip i{flex:1 1 0;height:11px;border-radius:3px;background:var(--coral);opacity:.16;
transition:opacity .45s ease, background .45s ease, box-shadow .45s ease}
.strip::before{content:'';position:absolute;top:0;bottom:0;width:30%;left:-40%;
mix-blend-mode:screen;filter:blur(7px);
background:linear-gradient(90deg, transparent, var(--coral), transparent);
animation:sweep 2.4s linear infinite}
@keyframes sweep{0%{left:-42%}100%{left:112%}}
.chip{display:inline-flex;align-items:center;gap:9px;margin-bottom:24px;
font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',monospace;
font-size:.66rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase;
color:var(--coral);padding:5px 14px;border-radius:100px;
border:1px solid color-mix(in srgb, var(--coral) 38%, transparent)}
.chip b{width:7px;height:7px;border-radius:1px;background:var(--coral);
box-shadow:var(--glow-coral);animation:beat 1.6s ease-in-out infinite}
.headline{font-family:'Orbitron','Segoe UI',sans-serif;font-weight:700;
font-size:clamp(2.5rem,11.5vw,3.6rem);line-height:.92;letter-spacing:.015em;
text-transform:uppercase;color:var(--coral);text-shadow:0 0 26px rgba(255,94,94,.34);
margin-bottom:20px;animation:rise .6s cubic-bezier(.16,1,.3,1) forwards, flicker 5.5s 1.4s ease-in-out infinite}
.headline span{display:block;color:var(--ink);text-shadow:none}
@keyframes flicker{0%,93%,100%{opacity:1}94%{opacity:.55}96%{opacity:.85}97%{opacity:.4}}
.copy{max-width:35ch;color:var(--dim);font-size:.99rem;line-height:1.62;margin-bottom:32px}
.btn{position:relative;overflow:hidden;cursor:pointer;border:none;border-radius:11px;
padding:14px 36px;color:#04140a;background:var(--signal);
font-family:'JetBrains Mono',ui-monospace,monospace;font-weight:700;
font-size:.8rem;letter-spacing:.18em;text-transform:uppercase;
box-shadow:0 0 0 1px color-mix(in srgb, var(--signal) 55%, transparent), 0 10px 30px rgba(76,175,80,.28);
transition:transform .15s ease, box-shadow .25s ease, background .25s ease}
.btn:hover{background:var(--signal-hi);box-shadow:0 0 0 1px var(--signal), 0 14px 40px rgba(76,175,80,.42)}
.btn:active{transform:translateY(1px) scale(.99)}
.btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--bg), 0 0 0 5px var(--signal)}
.btn[disabled]{cursor:default;opacity:.7}
.btn::after{content:'';position:absolute;inset:0;transform:translateX(-130%);
background:linear-gradient(90deg, transparent, rgba(255,255,255,.38), transparent)}
.btn:not([disabled]):hover::after{animation:sheen .85s ease}
@keyframes sheen{to{transform:translateX(130%)}}
.telemetry{margin-top:18px;min-height:1.1em;
font-family:'JetBrains Mono',ui-monospace,monospace;
font-size:.7rem;letter-spacing:.12em;color:var(--mute)}
.foot{margin-top:34px;font-family:'JetBrains Mono',ui-monospace,monospace;
font-size:.6rem;letter-spacing:.3em;text-transform:uppercase;color:var(--mute);opacity:.65}
/* ── link-restored state ── */
body.online .strip i{opacity:1;background:var(--signal);box-shadow:var(--glow-signal)}
body.online .strip::before{display:none}
body.online .brand-dot,body.online .chip b{background:var(--signal);box-shadow:var(--glow-signal);animation:none}
body.online .chip{color:var(--signal);border-color:color-mix(in srgb, var(--signal) 45%, transparent)}
body.online .headline{animation:none}
@media (prefers-reduced-motion: reduce){
.panel>*,.headline{animation:none;opacity:1;transform:none}
.strip::before{display:none}.strip i{opacity:.5}
.brand-dot,.chip b{animation:none}.btn:hover::after{animation:none}
}
</style>
</head>
<body>
<main class="panel" role="alert" aria-live="assertive">
<div class="brand"><span class="brand-dot" aria-hidden="true"></span><span class="brand-name">LED&nbsp;GRAB</span></div>
<div class="strip-wrap"><div class="strip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<p class="chip"><b aria-hidden="true"></b><span id="chip-text">No Signal</span></p>
<h1 class="headline"><span>Signal</span>Lost</h1>
<p class="copy">Can&rsquo;t reach the LED&nbsp;Grab server. Make sure it&rsquo;s running and your device is on the same network.</p>
<button id="retry" class="btn" type="button"><span id="btn-label">Reconnect</span></button>
<p class="telemetry" id="telemetry" aria-live="polite">Listening for the server&hellip;</p>
<p class="foot">LED&nbsp;Grab &middot; Local Console</p>
</main>
<script>
(function(){
var RETRY = 3; // seconds between automatic probes
var attempts = 0, checking = false, done = false, timer = null;
var tel = document.getElementById('telemetry');
var chipText = document.getElementById('chip-text');
var btn = document.getElementById('retry');
var btnLabel = document.getElementById('btn-label');
function pad(n){ return n < 10 ? '0' + n : '' + n; }
function say(m){ if (tel) tel.textContent = m; }
function probe(){
if (checking || done) return;
checking = true; attempts++;
if (btn) btn.disabled = true;
if (btnLabel) btnLabel.textContent = 'Checking';
say('Probing for signal · attempt ' + pad(attempts));
// Any settled response (even 401/403) means the server is reachable.
// Cache-bust + no-store so a stale SW cache can't fake a recovery.
fetch('/?_swping=' + Date.now(), { method: 'HEAD', cache: 'no-store' })
.then(restored)
.catch(function(){
checking = false;
if (btn) btn.disabled = false;
if (btnLabel) btnLabel.textContent = 'Reconnect';
countdown(RETRY);
});
}
function restored(){
done = true;
document.body.classList.add('online');
if (chipText) chipText.textContent = 'Link Restored';
if (btnLabel) btnLabel.textContent = 'Reconnecting';
say('Signal acquired — reloading');
setTimeout(function(){ location.reload(); }, 750);
}
function countdown(s){
if (done) return;
if (s <= 0){ probe(); return; }
say('Retrying in ' + pad(s) + 's · attempt ' + pad(attempts + 1));
timer = setTimeout(function(){ countdown(s - 1); }, 1000);
}
function probeNow(){ if (timer){ clearTimeout(timer); timer = null; } probe(); }
if (btn) btn.addEventListener('click', probeNow);
window.addEventListener('online', probeNow);
document.addEventListener('visibilitychange', function(){ if (!document.hidden) probeNow(); });
probe();
})();
</script>
</body>
</html>`;
// Install: pre-cache core shell
self.addEventListener('install', (event) => {
event.waitUntil(
@@ -66,17 +279,17 @@ self.addEventListener('fetch', (event) => {
return;
}
// Navigation: network-only (page requires auth, no useful offline fallback)
// Navigation: network-first with branded, self-healing offline fallback
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
new Response(
'<html><body style="font-family:system-ui;text-align:center;padding:60px 20px;background:#1a1a1a;color:#ccc">' +
'<h2>LED Grab</h2><p>Cannot reach the server. Check that it is running and you are on the same network.</p>' +
'<button onclick="location.reload()" style="margin-top:20px;padding:10px 24px;border-radius:8px;border:none;background:#4CAF50;color:#fff;font-size:1rem;cursor:pointer">Retry</button>' +
'</body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html' } }
)
new Response(OFFLINE_HTML, {
status: 503,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
})
)
);
return;
@@ -84,6 +84,14 @@
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="device-mqtt-source-group" style="display: none;">
<div class="label-row">
<label for="device-mqtt-source" data-i18n="device.mqtt_source">MQTT Broker:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.mqtt_source.hint">Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.</small>
<select id="device-mqtt-source"></select>
</div>
<div class="form-group" id="device-serial-port-group" style="display: none;">
<div class="label-row">
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
@@ -53,6 +53,15 @@
<select id="settings-serial-port"></select>
</div>
<div class="form-group" id="settings-mqtt-source-group" style="display: none;">
<div class="label-row">
<label for="settings-mqtt-source" data-i18n="device.mqtt_source">MQTT Broker:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.mqtt_source.hint">Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.</small>
<select id="settings-mqtt-source"></select>
</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>