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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user