diff --git a/server/src/ledgrab/api/graph_schema.py b/server/src/ledgrab/api/graph_schema.py index a8f7085..888c601 100644 --- a/server/src/ledgrab/api/graph_schema.py +++ b/server/src/ledgrab/api/graph_schema.py @@ -279,6 +279,54 @@ def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]: return [v for v in current if isinstance(v, str) and v] +def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int: + """Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``. + + The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable + grammar and replaces any leaf id present in ``id_map`` with its mapped value. + Ids absent from ``id_map`` (references to entities outside the remap set) are + left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound + bindables (a plain number where an object was expected) and missing keys are + tolerated. Returns the number of ids rewritten. + """ + segments = field_path.split(".") + # Descend to the container(s) that hold the final key. + parents: list[Any] = [entity] + for segment in segments[:-1]: + is_list = segment.endswith("[]") + key = segment[:-2] if is_list else segment + nxt: list[Any] = [] + for obj in parents: + if not isinstance(obj, dict): + continue + val = obj.get(key) + if is_list: + if isinstance(val, list): + nxt.extend(val) + elif isinstance(val, dict): + nxt.append(val) + parents = nxt + + last = segments[-1] + last_is_list = last.endswith("[]") + key = last[:-2] if last_is_list else last + count = 0 + for obj in parents: + if not isinstance(obj, dict): + continue + val = obj.get(key) + if last_is_list: + if isinstance(val, list): + for i, item in enumerate(val): + if isinstance(item, str) and item in id_map: + val[i] = id_map[item] + count += 1 + elif isinstance(val, str) and val in id_map: + obj[key] = id_map[val] + count += 1 + return count + + def serialize_entity(model: Any) -> dict[str, Any]: """Best-effort serialize a storage model to a plain dict for graph use. diff --git a/server/src/ledgrab/api/routes/graph.py b/server/src/ledgrab/api/routes/graph.py index b28a788..660a6a1 100644 --- a/server/src/ledgrab/api/routes/graph.py +++ b/server/src/ledgrab/api/routes/graph.py @@ -26,8 +26,12 @@ from ledgrab.api.graph_schema import ( ENTITY_KINDS, NODE_TYPE_FIELD, build_topology, + extract_refs, find_dependents, + remap_refs, schema_as_dicts, + schema_for_kind, + serialize_entity, serialize_entity_for_graph, validate_connection, ) @@ -122,3 +126,124 @@ async def validate_graph_connection( entities, body.target_kind, body.target_id, body.field, body.source_id ) return {"ok": ok, "error": error} + + +# ── Subgraph duplication (server-side blueprint instantiate) ───────────────── +# Only these kinds are cloned. They carry no inline secrets — they *reference* +# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id, +# and those are NOT cloned — and they have no hardware identity to conflict +# over. Output targets, automations, devices and integrations are out of scope. +_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source") +_MAX_DUPLICATE = 200 + + +class DuplicateRequest(BaseModel): + """Duplicate a selected subgraph of value / colour-strip sources.""" + + node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE) + name_suffix: str = Field(default=" (copy)", max_length=40) + + +def _unique_name(existing: set[str], desired: str) -> str: + """A name not already in ``existing`` (appends ' 2', ' 3', … on collision).""" + if desired not in existing: + return desired + i = 2 + while f"{desired} {i}" in existing: + i += 1 + return f"{desired} {i}" + + +def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]: + """Deep-clone selected value/colour-strip sources with new ids, rewiring + references that point *within* the selection (shared deps are left alone).""" + # Index every duplicable entity by id → (kind, store, model); track names. + index: dict[str, tuple[str, Any, Any]] = {} + existing_names: dict[str, set[str]] = {} + for kind in _DUPLICABLE_KINDS: + try: + store = _KIND_STORES[kind]() + models = store.get_all() + except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request + logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc) + continue + names = existing_names.setdefault(kind, set()) + for m in models: + mid = getattr(m, "id", None) + mname = getattr(m, "name", None) + if isinstance(mname, str): + names.add(mname) + if isinstance(mid, str) and mid: + index[mid] = (kind, store, m) + + selected: list[str] = [] + skipped: list[dict[str, str]] = [] + for nid in dict.fromkeys(node_ids): # de-dupe, preserve order + if nid in index: + selected.append(nid) + else: + skipped.append( + {"id": nid, "reason": "only value and colour-strip sources can be duplicated"} + ) + + # Pass 1 — create clones; their refs still point at the originals (valid). + id_map: dict[str, str] = {} + created: list[dict[str, str]] = [] + clones: list[tuple[str, Any, str]] = [] + for old_id in selected: + kind, store, model = index[old_id] + base = (getattr(model, "name", None) or old_id) + name_suffix + name = _unique_name(existing_names[kind], base) + existing_names[kind].add(name) + try: + new = store.clone(old_id, name) + except Exception as exc: # noqa: BLE001 + logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc) + skipped.append({"id": old_id, "reason": f"clone failed: {exc}"}) + continue + id_map[old_id] = new.id + created.append({"id": new.id, "kind": kind, "name": new.name}) + clones.append((kind, store, new.id)) + + # Pass 2 — rewrite references that point within the cloned set. + warnings: list[dict[str, str]] = [] + for kind, store, new_id in clones: + clone = serialize_entity(store.get(new_id)) + changed_roots: set[str] = set() + for cf in schema_for_kind(kind): + if remap_refs(clone, cf.field, id_map): + changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]")) + if not changed_roots: + continue + # `clone` is the FULL serialized entity, so each changed root carries a + # complete, structurally-intact value (the whole `layers` list / bindable + # dict) that ``update_source`` replaces or merges wholesale. (Within the + # duplicable set the only roots that change are scalar ids, `layers` and + # bindable slots — never a partially-built nested object.) + updates = {root: clone[root] for root in changed_roots if root in clone} + try: + store.update_source(new_id, **updates) + except Exception as exc: # noqa: BLE001 + logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc) + warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"}) + + # Safety net — a clone must never still reference an OLD (in-selection) id. + for kind, store, new_id in clones: + clone = serialize_entity(store.get(new_id)) + for cf in schema_for_kind(kind): + if any(ref in id_map for ref in extract_refs(clone, cf.field)): + warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"}) + + return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings} + + +@router.post("/api/v1/graph/duplicate", tags=["Graph"]) +async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]: + """Deep-clone the selected value/colour-strip sources (new ids, wiring remapped). + + References that point *within* the selection are rewired to the new clones; + references to entities outside it (devices, HA sources, …) stay shared with + the originals. Only value and colour-strip sources are cloned — they carry no + inline secrets — so any other kind in the selection is reported in ``skipped``. + """ + return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix) diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index bbdf5c2..57e36b9 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, graphShowIssues, graphExportTopology, + graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection, graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow, } from './features/graph-editor.ts'; @@ -627,6 +627,7 @@ Object.assign(window, { graphRelayout, graphShowIssues, graphExportTopology, + graphDuplicateSelection, 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 9fdc32e..faddfda 100644 --- a/server/src/ledgrab/static/js/core/graph-connections.ts +++ b/server/src/ledgrab/static/js/core/graph-connections.ts @@ -422,4 +422,36 @@ export async function updateListSlotConnection( } } +/* ── Subgraph duplication (D6) ─────────────────────────────────────── */ + +/** Result of `POST /graph/duplicate` (server-side clone of a selected subgraph). */ +export interface DuplicateResult { + id_map: Record; + created: Array<{ id: string; kind: string; name: string }>; + skipped: Array<{ id: string; reason: string }>; + warnings: Array<{ id: string; reason: string }>; +} + +/** + * Server-side duplicate of a selected subgraph: the backend deep-clones the + * value / colour-strip sources among `nodeIds` with fresh ids and rewires + * references that point *within* the selection (shared deps stay shared). + * Returns the result, or `null` on failure. Invalidates the affected caches so + * a subsequent graph reload shows the clones. + */ +export async function duplicateSubgraph( + nodeIds: string[], nameSuffix?: string, +): Promise { + try { + const body: Record = { node_ids: nodeIds }; + if (nameSuffix) body.name_suffix = nameSuffix; + const res = await apiPost('/graph/duplicate', body); + valueSourcesCache.invalidate(); + colorStripSourcesCache.invalidate(); + return res; + } catch { + return null; + } +} + export { CONNECTION_MAP }; diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index 77c8323..4de1abe 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -19,7 +19,7 @@ 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, updateListSlotConnection, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts'; +import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, duplicateSubgraph, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts'; import { showTypePicker } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.ts'; import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts'; @@ -809,6 +809,40 @@ export async function graphExportTopology(): Promise { } } +/** + * Duplicate the current node selection server-side. The backend clones the + * value / colour-strip sources in the selection (full config preserved, no + * secrets crossing the wire), rewires references that point *within* the + * selection, and shares everything else. The graph then reloads with the new + * cluster selected. + */ +export async function graphDuplicateSelection(): Promise { + const ids = [..._selectedIds]; + if (ids.length === 0) { + showToast(t('graph.duplicate_none'), 'info'); + return; + } + const res = await duplicateSubgraph(ids); + if (!res) { + showToast(t('graph.duplicate_failed'), 'error'); + return; + } + const newIds = Object.values(res.id_map || {}); + if (newIds.length === 0) { + showToast(t('graph.duplicate_none_eligible'), 'info'); + return; + } + await loadGraphEditor(); + _selectedIds = new Set(newIds); + const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null; + if (nodeGroup) updateSelection(nodeGroup, _selectedIds); + const hasWarn = !!res.warnings?.length; + showToast( + t(hasWarn ? 'graph.duplicate_done_warn' : 'graph.duplicate_done', { count: newIds.length }), + hasWarn ? 'info' : 'success', + ); +} + /** Frame and highlight all nodes flagged with configuration issues. */ export function graphShowIssues(): void { if (_issueIds.size === 0 || !_nodeMap || !_canvas) { @@ -1357,6 +1391,10 @@ function _graphHTML(): string { ${t('graph.export')} +