diff --git a/TODO.md b/TODO.md index 8012123..1e2698f 100644 --- a/TODO.md +++ b/TODO.md @@ -201,9 +201,19 @@ caller off the legacy path, then delete it. - [x] Field on `device_config.MQTTConfig` - [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()` - [x] Provider threads `mqtt_manager` via `ProviderDeps` -- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still - pending — backend accepts the field, but the device-create form doesn't - expose it yet)* +- [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned + out the API layer was *also* missing it (the TODO's "backend accepts the + field" was wrong — `mqtt_source_id` lived in `device_store` + + `device_config.MQTTConfig` but was dropped by `DeviceCreate/Update/Response` + and the routes). Added: schema fields + route threading + referenced-source + validation (`_validate_mqtt_source_exists`, mirrors output_targets) + + `except HTTPException: raise` guard in `update_device` (it was masking its + own 4xx as 500). Frontend: broker `EntitySelect` (reusing `mqttSourcesCache`) + in both the add-device (`device-discovery.ts`) and settings + (`devices.ts`) modals — shown for `device_type=mqtt`, wired into + load/save/validate/dirty-check/clone. Empty = "first available broker". + 4 regression tests in `test_devices_routes.py::TestMqttSourceId`; full + suite 1567 passing; en/ru/zh keys added. ### Phase 5 — `AutomationEngine` @@ -213,8 +223,11 @@ caller off the legacy path, then delete it. ### Phase 6 — `api/routes/system.py` - [x] Replace integration status with `mqtt_manager.get_all_sources_status()` -- [ ] Update frontend dashboard payload (MQTT widget now expects a list of - sources instead of a single `enabled`/`connected` pair — surface in UI) +- [x] Update frontend dashboard payload (MQTT widget now expects a list of + sources instead of a single `enabled`/`connected` pair — surface in UI). + Done: `dashboard.ts` `_renderMQTTIntegrationCard` renders one card per + `mqttStatus.connections` entry; `_updateIntegrationsInPlace` iterates the + list. ### Phase 7 — Startup migration diff --git a/server/src/ledgrab/__main__.py b/server/src/ledgrab/__main__.py index 4ca8dcd..d185fb2 100644 --- a/server/src/ledgrab/__main__.py +++ b/server/src/ledgrab/__main__.py @@ -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: diff --git a/server/src/ledgrab/android_entry.py b/server/src/ledgrab/android_entry.py index ea1a8d9..f78905f 100644 --- a/server/src/ledgrab/android_entry.py +++ b/server/src/ledgrab/android_entry.py @@ -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 diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index 70737b8..d7c4ed8 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -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"] diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index e22dcb8..6f5e68c 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -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 diff --git a/server/src/ledgrab/api/graph_schema.py b/server/src/ledgrab/api/graph_schema.py new file mode 100644 index 0000000..30d674a --- /dev/null +++ b/server/src/ledgrab/api/graph_schema.py @@ -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 diff --git a/server/src/ledgrab/api/routes/_mqtt_validation.py b/server/src/ledgrab/api/routes/_mqtt_validation.py new file mode 100644 index 0000000..ad953d4 --- /dev/null +++ b/server/src/ledgrab/api/routes/_mqtt_validation.py @@ -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") diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index af16a6b..8edc0ea 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -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, diff --git a/server/src/ledgrab/api/routes/graph.py b/server/src/ledgrab/api/routes/graph.py new file mode 100644 index 0000000..38fc74a --- /dev/null +++ b/server/src/ledgrab/api/routes/graph.py @@ -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} diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index e3de57c..1e75820 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -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, diff --git a/server/src/ledgrab/api/routes/snapshot.py b/server/src/ledgrab/api/routes/snapshot.py new file mode 100644 index 0000000..6b1d180 --- /dev/null +++ b/server/src/ledgrab/api/routes/snapshot.py @@ -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": [, ...], + "target_states": {target_id: , ...}, + "target_metrics": {target_id: , ...}, + "devices": [, ...], + "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 diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index 3264348..e5b1ce6 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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" ) diff --git a/server/src/ledgrab/demo.py b/server/src/ledgrab/demo.py index 1102fc0..d7300f6 100644 --- a/server/src/ledgrab/demo.py +++ b/server/src/ledgrab/demo.py @@ -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, ) diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 3de09dd..00a2ac5 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -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, ) diff --git a/server/src/ledgrab/shutdown_state.py b/server/src/ledgrab/shutdown_state.py index 953cd8f..2c73abd 100644 --- a/server/src/ledgrab/shutdown_state.py +++ b/server/src/ledgrab/shutdown_state.py @@ -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 diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index 86d5c99..60d67ec 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -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; diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 5a100cc..bbdf5c2 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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, diff --git a/server/src/ledgrab/static/js/core/graph-connections.ts b/server/src/ledgrab/static/js/core/graph-connections.ts index 5f2890d..4e06adc 100644 --- a/server/src/ledgrab/static/js/core/graph-connections.ts +++ b/server/src/ledgrab/static/js/core/graph-connections.ts @@ -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 { + try { + return await apiPost('/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 { + 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} success */ export async function updateConnection(targetId: string, targetKind: string, field: string, newSourceId: string | null): Promise { - 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 (`.source_id`) PUT `{ : { 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 = entry.bindable + ? { [field.split('.')[0]]: { source_id: newSourceId || '' } } + : { [field]: newSourceId }; try { await apiPut(url, body); diff --git a/server/src/ledgrab/static/js/core/graph-edges.ts b/server/src/ledgrab/static/js/core/graph-edges.ts index 11294fb..f14e9b2 100644 --- a/server/src/ledgrab/static/js/core/graph-edges.ts +++ b/server/src/ledgrab/static/js/core/graph-edges.ts @@ -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)); + } + }); } } diff --git a/server/src/ledgrab/static/js/core/graph-layout.ts b/server/src/ledgrab/static/js/core/graph-layout.ts index 3a7442a..b0b31db 100644 --- a/server/src/ledgrab/static/js/core/graph-layout.ts +++ b/server/src/ledgrab/static/js/core/graph-layout.ts @@ -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; 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 { 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(); + // Index nodes by id so edge-building is O(1) instead of O(N) per edge. + const nodeByIdLocal = new Map(); function addNode(id: string, kind: string, name: string, subtype: string, extra: Record = {}): 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 ── */ diff --git a/server/src/ledgrab/static/js/core/graph-nodes.ts b/server/src/ledgrab/static/js/core/graph-nodes.ts index 5923cd6..d758403 100644 --- a/server/src/ledgrab/static/js/core/graph-nodes.ts +++ b/server/src/ledgrab/static/js/core/graph-nodes.ts @@ -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, } } +/** + * 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): 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. */ diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index d405bef..a47d690 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -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 = `` + + sources.map((s: MQTTSource) => ``).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'; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 5f8643a..8f19fe5 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -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 = `` + + sources.map((s: MQTTSource) => ``).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; body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== ''); diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index 9ddf4f8..4de96c5 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -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 = 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 { + 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 { + const obj = readJson(_POS_KEY, _isPositionMap); + const map = new Map(); + 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 = {}; + for (const [id, p] of _manualPositions) obj[id] = p; + writeJson(_POS_KEY, obj); +} +let _manualPositions: Map = _loadManualPositions(); + +// Node IDs that currently have a configuration issue (broken ref / cycle). +let _issueIds: Set = 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 = 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 { 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 { 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 { 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, edges: any[]): Set { + const adj = new Map(); + 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(); + const inCycle = new Set(); + + 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(); + 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 { + try { + const topo = await apiGet('/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 => `${d}`; 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 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(); - 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(); 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 { + @@ -1098,6 +1349,10 @@ function _graphHTML(): string { ${t('graph.fullscreen')} +
+

Listening for the server…

+

LED Grab · Local Console

+ + + +`; + // 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( - '' + - '

LED Grab

Cannot reach the server. Check that it is running and you are on the same network.

' + - '' + - '', - { 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; diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index bddaf73..5e0a40e 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -84,6 +84,14 @@ +