feat(graph): make the visual editor a full wiring control surface

Lets users wire the system end-to-end from the graph, and fixes the core
bug that made drag-to-wire silently fail.

- Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes
  the target's discriminator (source_type/stream_type/target_type) into the
  partial PUT, so value/colour-strip/audio/picture sources and output targets
  all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py.
- Re-wire composite layers / mapped zones from the graph (right-click a
  layer/zone source edge -> Re-wire). Whole-list write preserves every sibling
  layer/zone setting, with an optimistic-concurrency guard and undo.
- Secret-safe /graph topology: project entities to id/name/subtype + reference
  roots so the endpoint cannot leak webhook tokens or other credentials.
- Carry slot indices on list edges; node custom-icon + schema-drift refinements;
  rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
This commit is contained in:
2026-05-29 02:29:19 +03:00
parent 05cf121666
commit 2e51f46dfd
13 changed files with 677 additions and 53 deletions
+57 -21
View File
@@ -1032,32 +1032,68 @@ viewer. Driven by the ULTRA-DEEP review (findings A1A5, B1B6, C1C6, D1
(only offers slots the target entity actually has). Writes the partial
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
preserving the static value. Verified data-safe (no `from_raw`/value-reset path).
- [x] Render the two functional value-source references `buildGraph` was missing —
`value_source.value_source_id` (gradient_map → inner value source) and
`value_source.color_strip_source_id` (css_extract → strip). Both are runtime-
resolved and already drag-editable; now visible/detachable in the graph.
- [x] **B4 foundation:** backend schema now authoritative about graph-editability
(`is_editable()` + `editable` flag in `/graph/schema`); `validate-connection`
hardened to reject non-editable fields (colour/list/double-nested), not just lists.
- [x] **B4 drift guard + gap fixes:** `checkSchemaDrift()` (graph-connections.ts) warns
once if the frontend `CONNECTION_MAP` editable set diverges from `/graph/schema`
(the automated "10-step checklist"). Surfacing it found 3 real gaps; fixed 2:
`color_strip_source.input_source_id` + `processing_template_id` are now drag-editable
(processed-strip wiring; `apply_update` is partial-safe). The 3rd —
`device.default_css_processing_template_id` — is intentionally NOT drag-editable
(the device PUT route isn't partial-safe; a one-field PUT could null the URL) and is
in the drift-check exclude set. Also broadened `_availableMatches` to hide any slot
the target entity doesn't expose (subtype-accurate; refs are always-emitted so empty
slots stay wireable). Review also caught a **dead `output_target.picture_source_id`
slot** (no output target stores it — not a field/schema, never emitted) — removed
from both registries + `buildGraph`.
- [x] **Comprehensive review pass (4 subagents: backend/frontend-core/orchestrator/security).**
Findings fixed:
- **CRITICAL (security):** `GET /api/v1/graph` leaked plaintext **webhook tokens**
(`asdict` recursed `Automation.rules[].token`, an auth-equivalent secret). Fixed with
**field-projection** — `serialize_entity_for_graph()` / `graph_field_roots()` project
each entity to only `{id, name, subtype, reference-roots}`; secrets can't survive.
Added a structural regression test asserting no projection root is secret-bearing for
any kind (drift-proof boundary) + a token-drop test.
- MEDIUM: added missing `value_source.clock_id` (AnimatedColorValueSource → sync_clock)
to the backend registry for topology/dependents completeness (drift-excluded on the
frontend — value-source PUT needs a `source_type` discriminator, so it's editor-only).
- MEDIUM/LOW: `CSS.escape` on the markIssues id selector; grouped/clarified
`_DRIFT_EXCLUDE`; fixed the stale `_availableMatches` JSDoc; documented the
`checkSchemaDrift` forward-reference. Orchestrator + frontend-core + security: APPROVE.
- Verification: `npm --prefix server run typecheck` + `run build` clean; ruff clean;
graph backend tests 24 pass; full backend suite 1614 pass. 6 code-review passes,
graph backend tests 35 pass; full backend suite green. ~8 code-review passes,
all CRITICAL/HIGH findings fixed.
### Left to do (deferred)
- [ ] **BindableColor slots** (`color`, `color_peak`, `fallback_color`,
`default_color` on color_strip_source) — left non-editable in B1 because
scalar-value-source → colour-slot semantics are unclear. **First check:** do
colour-producing value sources exist? If a value_source can drive a colour,
mark these 4 CONNECTION_MAP entries `bindable: true` (they already validate on
the backend); the write path (`{ color: { source_id } }` → `BindableColor.apply_update`)
is already value-preserving. If not, leave read-only.
- [ ] **B4 — delete the frontend `CONNECTION_MAP` duplication.** Blocked on a backend
write endpoint. Plan:
1. Add `PUT /api/v1/graph/connection` (body: target_kind/id, field, source_id)
that validates (reuse `validate_connection`) then APPLIES the write
server-side — top-level via the owning store's update; single-level bindable
via `apply_update`. Must reuse each entity's existing update path (validation,
factory reconstruction, entity_changed event) — do NOT hand-roll per-store
mutation. Highest regression risk; needs per-kind tests.
2. Switch frontend `updateConnection`/`detachConnection` to call it.
3. Have the frontend fetch `/graph/schema` and build ports/edges from it,
then delete `CONNECTION_MAP` + the buildGraph edge duplication
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist
in `contexts/graph-editor.md`.
- [x] **BindableColor slots** — CHECKED, decision: keep read-only (won't fix).
Value sources are scalar-only (`ValueStream.get_value() -> float`) and every
colour consumer (`color_strip/single.py`, `effect_stream.py`) reads the static
RGB via `bcolor()`, ignoring `source_id`. So a value_source cannot drive a
colour — wiring `color`/`color_peak`/… would be a dead binding. Documented in
`api/graph_schema.py` next to the BindableColor entries. (Would only become
viable if a colour-producing value-source type is added.)
- [~] **B4 — delete the frontend `CONNECTION_MAP` duplication.**
- [x] **Foundation done:** the backend schema now carries an authoritative
`editable` flag per field (`is_editable()` in `api/graph_schema.py`, mirroring
the frontend `_isEditable`: top-level refs + single-level BindableFloat slots;
NOT colour/list/double-nested). `validate-connection` is hardened to reject any
non-editable field (was list-only). `editable` is surfaced in `/graph/schema`.
- [ ] **Remaining (the refactor):** frontend fetches `/graph/schema` on load and
derives connection metadata + edges from it (port the `extract_refs` dot-path/list
grammar to TS), keeping only a tiny `kind → {endpoint, cache}` write-routing table;
then delete the field-level `CONNECTION_MAP` + the `buildGraph` edge loops
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist in
`contexts/graph-editor.md`. **A backend apply-write endpoint is NOT required** —
keep the proven per-entity PUT. Risk: regressing drag-connect/bindable; keep a
dev drift-check (frontend editable set vs `/graph/schema`) during the transition.
Note: frontend `CONNECTION_MAP` also has inert `ha_source_id`/`gradient_id` entries
(no graph node kind) — drop them, the backend schema already omits them.
- [ ] **D6 — blueprint import/instantiate.** Export exists; the apply half (serialize
a selected subgraph's topology + entities, re-import with id remapping, conflict
handling) is large and data-integrity-sensitive (see Data Migration Policy in
+67 -7
View File
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
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"),
# AnimatedColorValueSource references a sync clock for shared timing.
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
# ── 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"),
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
)
),
# ── Color strip sources (BindableColor value bindings) ──
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
# (`get_value() -> float`) and every colour consumer reads the static RGB via
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
# read-only; do not enable them without a colour-producing value source.
*(
ConnectionField(
"color_strip_source",
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
# ── 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
),
@@ -201,6 +207,34 @@ def schema_for_kind(kind: str) -> list[ConnectionField]:
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
# BindableColor slots are structurally bindable but NOT graph-editable: a
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
# value source cannot drive a colour.
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
{
"color.source_id",
"color_peak.source_id",
"fallback_color.source_id",
"default_color.source_id",
}
)
def is_editable(cf: ConnectionField) -> bool:
"""Whether a field can be wired from the graph.
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
List slots (need an element index), double-nested fields, and the dead
colour bindings stay read-only.
"""
if cf.is_list:
return False
if not cf.nested:
return True
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
def schema_as_dicts() -> list[dict[str, Any]]:
"""Serialize the registry for the ``/graph/schema`` endpoint."""
return [
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
"bindable": c.bindable,
"nested": c.nested,
"is_list": c.is_list,
"editable": is_editable(c),
}
for c in CONNECTION_SCHEMA
]
@@ -269,6 +304,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
return {}
def graph_field_roots(kind: str) -> set[str]:
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
field, and the root segment of every reference path for that kind."""
roots: set[str] = {"id", "name"}
type_field = NODE_TYPE_FIELD.get(kind, "")
if type_field:
roots.add(type_field)
for cf in CONNECTION_SCHEMA:
if cf.target_kind == kind:
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
return roots
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
"""Serialize a model and project it to ONLY the keys the graph needs.
This projection is a **security boundary**: a full ``asdict``/``to_dict``
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
field except ``id``/``name``, the subtype field and reference-path roots is
dropped before the data reaches the graph API.
"""
full = serialize_entity(model)
roots = graph_field_roots(kind)
return {k: v for k, v in full.items() if k in roots}
# ── Topology / validation ───────────────────────────────────────────────────
@@ -480,12 +541,11 @@ def validate_connection(
)
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 is_editable(cf):
# List slots (need an element index), double-nested fields, and dead
# colour bindings can't be wired from the graph — edit via the entity
# editor instead.
return False, f"Field '{field}' is not editable via the graph"
if not _entity_exists(entities_by_kind, target_kind, target_id):
return False, f"Target entity not found: {target_id}"
if not source_id:
+2 -2
View File
@@ -28,7 +28,7 @@ from ledgrab.api.graph_schema import (
build_topology,
find_dependents,
schema_as_dicts,
serialize_entity,
serialize_entity_for_graph,
validate_connection,
)
@@ -78,7 +78,7 @@ def _gather_entities() -> dict[str, list[dict[str, Any]]]:
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
out[kind] = []
continue
out[kind] = [serialize_entity(m) for m in models]
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
return out
@@ -8,6 +8,7 @@ import {
streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj,
} from './state.ts';
import { logError } from './log.ts';
/** Result of the backend pre-write connection validator. */
export interface ConnectionValidation {
@@ -62,6 +63,69 @@ export async function getDependents(kind: string, id: string): Promise<GraphDepe
}
}
/* ── Schema drift guard (B4) ───────────────────────────────────── */
// Backend-declared reference fields the frontend intentionally does NOT drag-edit
// (the backend still lists them for topology/dependents completeness, so the drift
// check ignores them). Two categories:
// (a) the source kind is not a graph node — nothing to drag from.
// (b) the owning entity's PUT route is not safely partial-writable via a single
// dragged field, so it's edited through the entity editor instead.
const _DRIFT_EXCLUDE = new Set<string>([
// (a) no graph node for the source kind — nothing to drag from:
'value_source|ha_source_id',
'value_source|gradient_id',
// (b) not safely partial-PUT-able from a single dragged field:
'device|default_css_processing_template_id', // a one-field device PUT could null the URL
// (c) editable in principle but not surfaced as a graph edge yet:
'value_source|clock_id', // sync_clock → animated_colour value-source timing
]);
let _driftChecked = false;
interface SchemaConnection { target_kind: string; field: string; editable: boolean; }
/**
* Dev safety net for the B4 finding: warn once if the frontend CONNECTION_MAP's
* editable set diverges from the backend `/graph/schema` (the drift the manual
* "10-step checklist" guards against). Read-only — never affects the graph.
* No-op against older servers without the endpoint.
*
* Note: this references `CONNECTION_MAP` (const) and `_isEditable` (fn) declared
* later in the module — safe because it is only ever invoked at runtime (from
* `loadGraphEditor`), well after module initialization.
*/
export async function checkSchemaDrift(): Promise<void> {
if (_driftChecked) return;
_driftChecked = true;
let connections: SchemaConnection[];
try {
connections = (await apiGet<{ connections: SchemaConnection[] }>('/graph/schema')).connections || [];
} catch {
return; // endpoint unavailable — nothing to compare against
}
const k = (kind: string, field: string): string => `${kind}|${field}`;
const backend = new Set<string>();
for (const c of connections) {
if (c.editable && !_DRIFT_EXCLUDE.has(k(c.target_kind, c.field))) backend.add(k(c.target_kind, c.field));
}
const frontend = new Set<string>();
for (const c of CONNECTION_MAP) {
if (_isEditable(c) && !_DRIFT_EXCLUDE.has(k(c.targetKind, c.field))) frontend.add(k(c.targetKind, c.field));
}
const missingOnFrontend = [...backend].filter(key => !frontend.has(key));
const missingOnBackend = [...frontend].filter(key => !backend.has(key));
if (missingOnFrontend.length || missingOnBackend.length) {
logError('graph.schema_drift', new Error(
`CONNECTION_MAP drift vs /graph/schema — editable fields missing on frontend: ` +
`[${missingOnFrontend.join(', ')}]; missing on backend: [${missingOnBackend.join(', ')}]`,
));
}
}
/* ── Types ────────────────────────────────────────────────────── */
interface ConnectionEntry {
@@ -117,12 +181,14 @@ const CONNECTION_MAP: ConnectionEntry[] = [
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
// Processed strip: input source + processing template (apply_update is partial-safe)
{ targetKind: 'color_strip_source', field: 'input_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
{ targetKind: 'color_strip_source', field: 'processing_template_id', sourceKind: 'cspt', edgeType: 'template', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
// 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, 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 },
@@ -206,6 +272,25 @@ export function getConnectionByField(targetKind: string, field: string): Connect
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
}
/**
* Entity kinds whose per-entity PUT route validates the body against a Pydantic
* **discriminated union** (`Body(discriminator=...)`). Such a route 422s unless
* the body echoes the discriminator field, so a partial wiring write (just a
* reference field) is rejected outright. Maps the target kind → the
* discriminator's body-field name; the value is the target's *current* subtype,
* which we read back from the entity immediately before the write.
*
* Without this, drag-to-wire silently fails for nearly every source kind. Keep
* in sync with the backend `NODE_TYPE_FIELD` map in `api/graph_schema.py`.
*/
const _DISCRIMINATOR_FIELD: Readonly<Record<string, string>> = {
picture_source: 'stream_type',
audio_source: 'source_type',
value_source: 'source_type',
color_strip_source: 'source_type',
output_target: 'target_type',
};
/**
* Update a connection: set the reference field on the target entity.
* @param {string} targetId - The target entity's ID
@@ -226,6 +311,21 @@ export async function updateConnection(targetId: string, targetKind: string, fie
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
: { [field]: newSourceId };
// Discriminated-union PUT routes reject a body without their discriminator.
// Echo the target's current subtype so a partial wiring write validates
// instead of 422-ing. Best-effort: a failed read leaves the PUT to fail as
// before — it never makes things worse.
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
if (discrimField) {
try {
const current = await apiGet<Record<string, unknown>>(url);
const tag = current?.[discrimField];
if (typeof tag === 'string' && tag) body[discrimField] = tag;
} catch {
/* leave body as-is; the PUT below will surface any error */
}
}
try {
await apiPut(url, body);
// Invalidate the relevant cache so data refreshes
@@ -243,4 +343,83 @@ export async function detachConnection(targetId: string, targetKind: string, fie
return updateConnection(targetId, targetKind, field, '');
}
/* ── List-element slots (composite layers / mapped zones) ──────────── */
/**
* Targets that hold *list* reference slots. Editing one element means
* re-PUTting the whole list, so we map the kind → its endpoint + cache.
* (Only color strip sources have list slots today: composite `layers`,
* mapped `zones`.)
*/
const _LIST_SLOT_TARGET: Readonly<Record<string, { endpoint: string; cache: { invalidate(): void } }>> = {
color_strip_source: { endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
};
/**
* Re-wire a single element of a list reference slot — e.g. a composite
* `layers[index].source_id` or a mapped `zones[index].source_id`.
*
* The owning entity's PUT replaces the *entire* list, so this reads the entity
* back, copies every element verbatim, changes only `list[index][refField]`,
* and PUTs the full list (plus the discriminator). Echoing the existing element
* objects is what preserves each layer/zone's other settings (blend mode,
* opacity, LED range, per-layer brightness/template, …) — a naive partial write
* would silently drop that config.
*
* @param newSourceId New source id, or '' to clear (only valid for optional refs).
* @returns Promise<boolean> success
*/
export async function updateListSlotConnection(
targetId: string,
targetKind: string,
listField: string,
index: number,
refField: string,
newSourceId: string | null,
expectedCurrent?: string | null,
): Promise<boolean> {
const target = _LIST_SLOT_TARGET[targetKind];
if (!target || !Number.isInteger(index) || index < 0) return false;
const url = target.endpoint.replace('{id}', targetId);
try {
const current = await apiGet<Record<string, unknown>>(url);
const list = current?.[listField];
if (!Array.isArray(list) || index >= list.length) return false;
// Optimistic-concurrency guard: `index` is positional, so if the list was
// reordered/edited out-of-band (e.g. via the entity editor) between render
// and write — or between an action and its undo/redo — that index now points
// at a *different* element. Refuse rather than rewrite the wrong slot.
if (expectedCurrent != null) {
const el = list[index] as Record<string, unknown>;
const actual = typeof el?.[refField] === 'string' ? (el[refField] as string) : '';
if (actual !== expectedCurrent) return false;
}
// Copy every element; change only the one ref on the targeted element.
// (`|| ''` clears the ref — only valid for *optional* refs; the graph only
// re-wires the required `source_id`, so callers always pass a real id here.)
const nextList = list.map((el, i) =>
i === index
? { ...(el as Record<string, unknown>), [refField]: newSourceId || '' }
: { ...(el as Record<string, unknown>) },
);
const body: Record<string, unknown> = { [listField]: nextList };
// Discriminated-union PUT routes need the subtype echoed (see updateConnection).
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
if (discrimField) {
const tag = current[discrimField];
if (typeof tag === 'string' && tag) body[discrimField] = tag;
}
await apiPut(url, body);
target.cache.invalidate();
return true;
} catch {
return false;
}
}
export { CONNECTION_MAP };
@@ -19,6 +19,9 @@ interface GraphEdge {
type: string;
field?: string;
editable?: boolean;
/** List-element reference (composite layer / mapped zone) — exposed as
* `data-slot-*` so the editor can re-wire just this slot. */
slot?: { list: string; index: number; ref: string };
points?: { x: number; y: number }[] | null;
fromNode?: GraphNodeRect;
toNode?: GraphNodeRect;
@@ -124,6 +127,12 @@ function _renderEdge(edge: GraphEdge): SVGElement {
'data-to': to,
'data-field': field || '',
});
// List-element reference: expose the slot so the editor can re-wire it.
if (edge.slot) {
path.setAttribute('data-slot-list', edge.slot.list);
path.setAttribute('data-slot-index', String(edge.slot.index));
path.setAttribute('data-slot-ref', edge.slot.ref);
}
// Tooltip
const title = svgEl('title');
@@ -29,6 +29,10 @@ interface LayoutEdge {
label: string;
type: string;
editable: boolean;
/** For list-element references (composite layers / mapped zones): which list,
* which element index, and the reference field on that element. Lets the
* editor re-wire one slot without disturbing its siblings. */
slot?: { list: string; index: number; ref: string };
}
interface LayoutResult {
@@ -236,7 +240,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
nodeByIdLocal.set(id, node);
}
function addEdge(from: string, to: string, field: string, label: string = ''): void {
function addEdge(from: string, to: string, field: string, label: string = '', slot?: { list: string; index: number; ref: string }): void {
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 —
@@ -254,7 +258,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
);
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
const editable = !field.includes('.');
edges.push({ from, to, field, label, type, editable });
edges.push({ from, to, field, label, type, editable, ...(slot ? { slot } : {}) });
}
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
@@ -348,6 +352,11 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
for (const s of e.valueSources || []) {
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
// Derived value sources: gradient_map derives from an inner value source;
// css_extract derives from a color strip. Both are real, runtime-resolved
// references (and drag-editable) — render them so they're visible.
if (s.value_source_id) addEdge(s.value_source_id, s.id, 'value_source_id');
if (s.color_strip_source_id) addEdge(s.color_strip_source_id, s.id, 'color_strip_source_id');
}
// Color strip source edges
@@ -360,19 +369,20 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
// Composite layers
// Composite layers — carry the slot index so each `layer.source_id`
// edge can be re-wired individually from the graph (siblings untouched).
if (s.layers) {
for (const layer of s.layers) {
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
s.layers.forEach((layer: any, i: number) => {
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id', '', { list: 'layers', index: i, ref: 'source_id' });
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
}
});
}
// Mapped zones
// Mapped zones — carry the slot index (re-wirable from the graph).
if (s.zones) {
for (const zone of s.zones) {
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
}
s.zones.forEach((zone: any, i: number) => {
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id', '', { list: 'zones', index: i, ref: 'source_id' });
});
}
// Advanced picture calibration lines
@@ -405,7 +415,6 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
const transVsId = bindableSourceId(t.transition);
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
// KC target settings
if (t.settings) {
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
@@ -656,7 +656,7 @@ export function markIssues(group: SVGGElement, issues: Map<string, string[]>): v
for (const [id, msgs] of issues) {
if (!msgs.length) continue;
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
const el = group.querySelector(`.graph-node[data-id="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add('has-issue');
@@ -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, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, 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';
@@ -358,6 +358,10 @@ export async function loadGraphEditor(): Promise<void> {
if (gc) gc.appendChild(overlay);
}
// Dev safety net: warn once if the frontend connection map drifts from the
// backend schema (fire-and-forget; never blocks the graph).
void checkSchemaDrift();
try {
const entities = await _fetchAllEntities();
// Index raw entities by id for subtype-safe bindable-slot resolution.
@@ -2628,16 +2632,22 @@ function _onConnectPointerUp(e: PointerEvent): void {
}
/**
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
* e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
* `smoothing`. Non-bindable matches always pass through.
* Keep only the slots the target entity actually exposes (subtype-safe) a
* field is offered iff its first path segment is a key on the serialized entity.
* E.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
* `smoothing`; a processed strip offers `input_source_id`. Applies to ALL
* matches (bindable and top-level alike); reference fields are always emitted by
* `to_dict` so empty slots stay wireable.
*/
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
function _availableMatches<T extends { field: string }>(matches: T[], targetId: string): T[] {
const ent = _entitiesById.get(targetId);
return matches.filter(m => {
if (!m.bindable || !ent) return true;
return m.field.split('.')[0] in ent;
});
if (!ent) return matches; // no data (e.g. freshly created) → don't over-filter
// Offer a field only if the target entity actually exposes its slot (its
// first path segment is a key on the serialized entity). Reference fields
// are always emitted by `to_dict` even when empty, so empty slots stay
// wireable (B2); subtype-specific fields (e.g. processed-strip
// `input_source_id`) are correctly hidden on subtypes that lack them.
return matches.filter(m => m.field.split('.')[0] in ent);
}
/** Ask the user which field to wire when a source maps to multiple target fields. */
@@ -2841,6 +2851,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
_dismissEdgeContextMenu();
const field = edgePath.getAttribute('data-field') || '';
// List-slot edges (composite layers / mapped zones) aren't editable through
// the flat (to, field) path, but each carries its slot index so it can be
// re-wired individually.
if (edgePath.getAttribute('data-slot-list')) {
_showListSlotRewireMenu(edgePath, e, container);
return;
}
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to') ?? '';
@@ -2877,6 +2894,73 @@ function _dismissEdgeContextMenu(): void {
}
}
/**
* Re-wire menu for a list-slot edge (a composite layer or mapped zone source).
* Each such edge carries `data-slot-*`, so we can replace just that one
* element's reference and leave its siblings (and its own other settings)
* untouched. The picker lists every node of the source's kind; the backend
* rejects self-reference / cycles.
*/
function _showListSlotRewireMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
const toId = edgePath.getAttribute('data-to') ?? ''; // composite/mapped owner
const fromId = edgePath.getAttribute('data-from') ?? ''; // current source occupant
const listField = edgePath.getAttribute('data-slot-list') ?? '';
const index = parseInt(edgePath.getAttribute('data-slot-index') ?? '', 10);
const refField = edgePath.getAttribute('data-slot-ref') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode || Number.isNaN(index)) return;
const sourceKind = _nodeMap?.get(fromId)?.kind ?? 'color_strip_source';
const candidates = [...(_nodeMap?.values() ?? [])]
.filter((n: any) => n.kind === sourceKind && n.id !== toId && n.id !== fromId)
.sort((a: any, b: any) => (a.name || a.id).localeCompare(b.name || b.id));
const menu = document.createElement('div');
menu.className = 'graph-edge-menu';
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item';
btn.textContent = t('graph.rewire');
btn.addEventListener('click', () => {
_dismissEdgeContextMenu();
if (candidates.length === 0) { showToast(t('graph.no_compatible_connection'), 'info'); return; }
showTypePicker({
title: t('graph.rewire_choose_source'),
items: candidates.map((n: any) => ({ value: n.id, icon: _ico(P.link), label: n.name || n.id })),
onPick: (newSourceId: string) => { void _doListSlotRewire(toId, toNode.kind, listField, index, refField, newSourceId, fromId); },
});
});
menu.appendChild(btn);
container.querySelector('.graph-container')!.appendChild(menu);
_edgeContextMenu = menu;
}
/** Re-wire one list slot (composite layer / mapped zone) and record an undoable action. */
async function _doListSlotRewire(
targetId: string, targetKind: string, listField: string, index: number,
refField: string, newSourceId: string, prevSourceId: string,
): Promise<void> {
if (newSourceId === prevSourceId) return;
// Pass the expected current occupant so the write refuses if the slot was
// reordered/edited out-of-band (positional `index` could otherwise hit the
// wrong element). Each step expects the slot to still hold what it replaces.
const ok = await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId);
if (ok) {
pushUndoAction({
label: t('graph.action.rewire'),
undo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, prevSourceId, newSourceId))) throw new Error(t('graph.connection_failed')); },
redo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId))) throw new Error(t('graph.connection_failed')); },
});
showToast(t('graph.connection_updated'), 'success');
await loadGraphEditor();
} else {
showToast(t('graph.connection_failed'), 'error');
}
}
async function _detachSelectedEdge(): Promise<void> {
if (!_selectedEdge) return;
const { from, to, field, targetKind } = _selectedEdge;
@@ -2590,7 +2590,10 @@
"graph.action.connect": "Connect",
"graph.action.disconnect": "Disconnect",
"graph.action.move": "Move node",
"graph.action.rewire": "Re-wire slot",
"graph.choose_connection": "Choose connection",
"graph.rewire": "Re-wire…",
"graph.rewire_choose_source": "Choose a new source",
"graph.issues": "Issues",
"graph.issues_none": "No issues found",
"graph.issue.broken_ref": "Broken reference: {field}",
@@ -2272,7 +2272,10 @@
"graph.action.connect": "Соединить",
"graph.action.disconnect": "Отсоединить",
"graph.action.move": "Переместить узел",
"graph.action.rewire": "Переподключить слот",
"graph.choose_connection": "Выберите соединение",
"graph.rewire": "Переподключить…",
"graph.rewire_choose_source": "Выберите новый источник",
"graph.issues": "Проблемы",
"graph.issues_none": "Проблем не найдено",
"graph.issue.broken_ref": "Битая ссылка: {field}",
@@ -2268,7 +2268,10 @@
"graph.action.connect": "连接",
"graph.action.disconnect": "断开连接",
"graph.action.move": "移动节点",
"graph.action.rewire": "重新连接槽位",
"graph.choose_connection": "选择连接",
"graph.rewire": "重新连接…",
"graph.rewire_choose_source": "选择新的来源",
"graph.issues": "问题",
"graph.issues_none": "未发现问题",
"graph.issue.broken_ref": "无效引用:{field}",
+80 -1
View File
@@ -4,6 +4,10 @@ These exercise reference extraction, topology building, dependents, cycle and
dangling-reference detection without booting the app or any store.
"""
import json
import re
from dataclasses import dataclass, field
from ledgrab.api.graph_schema import (
CONNECTION_SCHEMA,
ENTITY_KINDS,
@@ -11,7 +15,10 @@ from ledgrab.api.graph_schema import (
detect_cycles,
extract_refs,
find_dependents,
graph_field_roots,
is_editable,
schema_for_kind,
serialize_entity_for_graph,
validate_connection,
would_create_cycle,
)
@@ -249,7 +256,79 @@ def test_validate_connection_rejects_list_field():
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
)
assert ok is False
assert "List connection" in err
assert "not editable" in err
def test_validate_connection_rejects_color_bindable():
# Colour bindings are structurally bindable but not graph-editable (a value
# source can't drive a colour).
entities = {
"color_strip_source": [
{"id": "css_1", "name": "X", "source_type": "single_color", "color": [255, 0, 0]}
],
"value_source": [{"id": "vs_1", "name": "V", "source_type": "static"}],
}
ok, err = validate_connection(
entities, "color_strip_source", "css_1", "color.source_id", "vs_1"
)
assert ok is False
assert "not editable" in err
@dataclass
class _FakeRule:
token: str = "SUPER_SECRET_WEBHOOK_TOKEN"
@dataclass
class _FakeAutomation:
id: str = "auto_1"
name: str = "A"
enabled: bool = True
scene_preset_id: str = "sp_1"
rules: list = field(default_factory=lambda: [_FakeRule()])
# Keys that must NEVER appear in the /graph projection allowlist.
_SECRET_KEY_RE = re.compile(
r"token|password|secret|credential|api[_-]?key|_key$|username|\burl\b|\bhost\b", re.I
)
def test_projection_roots_never_expose_secrets():
# The /graph projection (graph_field_roots) is the leak boundary. Assert no
# kind's allowlist contains a secret-bearing key — locks the boundary against
# future schema drift (a new reference field whose root looks like a secret).
for kind in ENTITY_KINDS:
for root in graph_field_roots(kind):
assert not _SECRET_KEY_RE.search(
root
), f"{kind}: projected root {root!r} looks secret-bearing"
def test_serialize_entity_for_graph_drops_secrets():
# The graph projection must strip everything except id/name/type + reference
# roots — a deep asdict would otherwise leak the webhook token (a real,
# auth-equivalent secret) in the /graph response.
projected = serialize_entity_for_graph("automation", _FakeAutomation())
assert projected["id"] == "auto_1"
assert projected["name"] == "A"
assert projected["scene_preset_id"] == "sp_1" # reference root kept
assert "rules" not in projected # non-reference field dropped
assert "enabled" not in projected
assert "SUPER_SECRET_WEBHOOK_TOKEN" not in json.dumps(projected)
def test_is_editable_classifies_fields():
by_field = {(c.target_kind, c.field): c for c in CONNECTION_SCHEMA}
# Top-level reference and single-level BindableFloat → editable.
assert is_editable(by_field[("output_target", "device_id")])
assert is_editable(by_field[("color_strip_source", "brightness.source_id")])
assert is_editable(by_field[("output_target", "transition.source_id")])
# Colour binding, list slot, and double-nested → NOT editable.
assert not is_editable(by_field[("color_strip_source", "color.source_id")])
assert not is_editable(by_field[("color_strip_source", "layers[].source_id")])
assert not is_editable(by_field[("output_target", "settings.brightness.source_id")])
def test_validate_connection_rejects_cycle():
@@ -0,0 +1,159 @@
"""Contract tests for graph drag-to-wire writes.
The graph editor's ``updateConnection()`` performs a *partial* PUT for a single
dragged edge: it sends only the reference (or bindable) field being wired. Five
entity kinds have ``Body(discriminator=...)`` PUT routes, so such a partial body
is rejected with a 422 unless it also echoes the entity's subtype
(``source_type`` / ``stream_type`` / ``target_type``). ``updateConnection()`` now
reads the target's subtype back and includes it.
These tests lock in that contract from the backend side: each ``(kind, field)``
pair the frontend ``CONNECTION_MAP`` drag-edits must validate as a minimal
``{discriminator, field}`` body and must be rejected without the discriminator
(the exact failure that silently broke wiring). If a future schema change makes
one of these fields required-with-siblings, or drops a subtype, these tests fail
and flag that graph wiring will break.
"""
import pytest
from pydantic import TypeAdapter, ValidationError
from ledgrab.api.schemas.audio_sources import AudioSourceUpdate
from ledgrab.api.schemas.color_strip_sources import ColorStripSourceUpdate
from ledgrab.api.schemas.output_targets import OutputTargetUpdate
from ledgrab.api.schemas.picture_sources import PictureSourceUpdate
from ledgrab.api.schemas.value_sources import ValueSourceUpdate
# Each row mirrors one drag-editable (target_kind, field) pair from the frontend
# CONNECTION_MAP, paired with a real subtype tag that owns that field and the
# body shape updateConnection() sends: a flat ref id, or a bindable
# ``{parent: {source_id}}`` slot (here keyed by the parent field root).
# (kind, update_union, discriminator_field, subtype_tag, body_field, sample_value)
_WIRING_WRITES = [
("value_source", ValueSourceUpdate, "source_type", "audio", "audio_source_id", "as_1"),
(
"value_source",
ValueSourceUpdate,
"source_type",
"adaptive_scene",
"picture_source_id",
"ps_1",
),
("value_source", ValueSourceUpdate, "source_type", "gradient_map", "value_source_id", "vs_1"),
(
"value_source",
ValueSourceUpdate,
"source_type",
"css_extract",
"color_strip_source_id",
"css_1",
),
(
"color_strip_source",
ColorStripSourceUpdate,
"source_type",
"picture",
"picture_source_id",
"ps_1",
),
(
"color_strip_source",
ColorStripSourceUpdate,
"source_type",
"audio",
"audio_source_id",
"as_1",
),
(
"color_strip_source",
ColorStripSourceUpdate,
"source_type",
"processed",
"input_source_id",
"css_2",
),
(
"color_strip_source",
ColorStripSourceUpdate,
"source_type",
"processed",
"processing_template_id",
"cspt_1",
),
# bindable BindableFloat slot — body is {<parent>: {source_id}}
(
"color_strip_source",
ColorStripSourceUpdate,
"source_type",
"audio",
"smoothing",
{"source_id": "vs_1"},
),
("audio_source", AudioSourceUpdate, "source_type", "capture", "audio_template_id", "at_1"),
("audio_source", AudioSourceUpdate, "source_type", "processed", "audio_source_id", "as_2"),
("picture_source", PictureSourceUpdate, "stream_type", "raw", "capture_template_id", "ct_1"),
("picture_source", PictureSourceUpdate, "stream_type", "processed", "source_stream_id", "ps_2"),
(
"picture_source",
PictureSourceUpdate,
"stream_type",
"processed",
"postprocessing_template_id",
"ppt_1",
),
("output_target", OutputTargetUpdate, "target_type", "led", "device_id", "dev_1"),
("output_target", OutputTargetUpdate, "target_type", "led", "color_strip_source_id", "css_1"),
# bindable slots on output targets
(
"output_target",
OutputTargetUpdate,
"target_type",
"led",
"brightness",
{"source_id": "vs_1"},
),
(
"output_target",
OutputTargetUpdate,
"target_type",
"ha_light",
"transition",
{"source_id": "vs_1"},
),
]
_IDS = [f"{kind}.{field}[{tag}]" for kind, _u, _d, tag, field, _v in _WIRING_WRITES]
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
def test_partial_wiring_put_validates_with_discriminator(kind, union, disc, tag, field, value):
"""A single dragged wiring edit validates when the subtype is echoed.
This is exactly what updateConnection() now sends; if it fails, drag-to-wire
for ``kind.field`` is broken.
"""
model = TypeAdapter(union).validate_python({disc: tag, field: value})
assert getattr(model, disc) == tag
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
def test_partial_wiring_put_rejected_without_discriminator(kind, union, disc, tag, field, value):
"""Without the discriminator the same body is rejected.
This is the 422 that silently broke drag-to-wire before updateConnection()
echoed the subtype guarding against anyone "simplifying" that away.
"""
with pytest.raises(ValidationError):
TypeAdapter(union).validate_python({field: value})
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
def test_partial_wiring_detach_validates_with_discriminator(kind, union, disc, tag, field, value):
"""Detach (clearing a slot) goes through the same partial-PUT path and must
also validate with the subtype echoed: ``{field: ""}`` for a flat reference,
``{parent: {source_id: ""}}`` for a bindable slot exactly what
``detachConnection()`` -> ``updateConnection(..., "")`` sends.
"""
cleared = {"source_id": ""} if isinstance(value, dict) else ""
model = TypeAdapter(union).validate_python({disc: tag, field: cleared})
assert getattr(model, disc) == tag