feat(graph): duplicate a selected subgraph server-side

A secret-safe equivalent of blueprint import: the graph editor's overflow menu
gains "Duplicate selection", which deep-clones the selected value and
colour-strip sources server-side (full config preserved, never crossing the
wire) and rewires references that point within the selection — shared
dependencies (devices, HA sources, …) stay shared.

- graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable
  grammar) that rewrites only in-selection ids; 8 unit tests.
- BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no
  field is lost), prefix-preserving fresh id; reusable by any store.
- POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value /
  colour-strip sources (no inline secrets), with a safety net flagging any
  unremapped reference; 7 integration tests vs real stores.
- Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection
  (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
This commit is contained in:
2026-05-29 11:45:55 +03:00
parent 2e51f46dfd
commit 15cfb821d3
12 changed files with 476 additions and 2 deletions
+48
View File
@@ -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.
+125
View File
@@ -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)
+2 -1
View File
@@ -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,
@@ -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<string, string>;
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<DuplicateResult | null> {
try {
const body: Record<string, unknown> = { node_ids: nodeIds };
if (nameSuffix) body.name_suffix = nameSuffix;
const res = await apiPost<DuplicateResult>('/graph/duplicate', body);
valueSourcesCache.invalidate();
colorStripSourcesCache.invalidate();
return res;
} catch {
return null;
}
}
export { CONNECTION_MAP };
@@ -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<void> {
}
}
/**
* 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<void> {
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 {
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>${t('graph.export')}</span>
</button>
<button onclick="graphDuplicateSelection(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>${t('graph.duplicate')}</span>
</button>
<div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
+1
View File
@@ -379,6 +379,7 @@ startTargetOverlay: (...args: any[]) => any;
graphRelayout: (...args: any[]) => any;
graphShowIssues: (...args: any[]) => any;
graphExportTopology: (...args: any[]) => any;
graphDuplicateSelection: (...args: any[]) => any;
graphToggleFullscreen: (...args: any[]) => any;
graphAddEntity: (...args: any[]) => any;
@@ -2604,6 +2604,12 @@
"graph.export": "Export graph (JSON)",
"graph.export_done": "Graph exported",
"graph.export_failed": "Failed to export graph",
"graph.duplicate": "Duplicate selection",
"graph.duplicate_none": "Select one or more nodes to duplicate",
"graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value & colour-strip sources)",
"graph.duplicate_done": "Duplicated {count} source(s)",
"graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped",
"graph.duplicate_failed": "Failed to duplicate selection",
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
"automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled",
@@ -2286,6 +2286,12 @@
"graph.export": "Экспорт графа (JSON)",
"graph.export_done": "Граф экспортирован",
"graph.export_failed": "Не удалось экспортировать граф",
"graph.duplicate": "Дублировать выбранное",
"graph.duplicate_none": "Выберите один или несколько узлов для дублирования",
"graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)",
"graph.duplicate_done": "Продублировано источников: {count}",
"graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать",
"graph.duplicate_failed": "Не удалось дублировать выбранное",
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
"automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена",
@@ -2282,6 +2282,12 @@
"graph.export": "导出图谱 (JSON)",
"graph.export_done": "图谱已导出",
"graph.export_failed": "导出图谱失败",
"graph.duplicate": "复制所选",
"graph.duplicate_none": "请选择一个或多个节点以复制",
"graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)",
"graph.duplicate_done": "已复制 {count} 个源",
"graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射",
"graph.duplicate_failed": "复制所选失败",
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
"automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用",
@@ -6,7 +6,10 @@ writes go through to SQLite immediately (write-through cache).
"""
import asyncio
import copy
import threading
import uuid
from datetime import datetime, timezone
from typing import Callable, Dict, Generic, List, TypeVar
from ledgrab.storage.database import Database
@@ -136,6 +139,41 @@ class BaseSqliteStore(Generic[T]):
await asyncio.to_thread(self._delete_item, item_id)
logger.info(f"Deleted {self._entity_name}: {item_id}")
def clone(self, item_id: str, new_name: str) -> T:
"""Faithfully duplicate an entity under a new id and name.
Deep-copies every field of the original (no serialize/deserialize
round-trip, so no field can be silently lost to a schema/dataclass name
mismatch), mints a fresh id that preserves the original's id prefix,
applies ``new_name`` and resets timestamps. References *inside* the clone
still point at whatever the original referenced — callers that want to
rewire intra-set references must do so after cloning.
SECURITY: this copies *every* field verbatim, including any secret a
model might hold. Callers must restrict cloning to non-secret-bearing
kinds (see ``_DUPLICABLE_KINDS`` in ``api/routes/graph.py``).
"""
with self._lock:
if item_id not in self._items:
from ledgrab.storage.base_store import EntityNotFoundError
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
self._check_name_unique(new_name)
new = copy.deepcopy(self._items[item_id])
prefix = item_id.rsplit("_", 1)[0] if "_" in item_id else item_id
new_id = f"{prefix}_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc)
new.id = new_id
new.name = new_name
if hasattr(new, "created_at"):
new.created_at = now
if hasattr(new, "updated_at"):
new.updated_at = now
self._items[new_id] = new
self._save_item(new_id, new)
logger.info("Cloned %s %s -> %s (%s)", self._entity_name, item_id, new_id, new_name)
return new
def count(self) -> int:
with self._lock:
return len(self._items)