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:
@@ -1032,32 +1032,68 @@ viewer. Driven by the ULTRA-DEEP review (findings A1–A5, B1–B6, C1–C6, D1
|
|||||||
(only offers slots the target entity actually has). Writes the partial
|
(only offers slots the target entity actually has). Writes the partial
|
||||||
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
|
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
|
||||||
preserving the static value. Verified data-safe (no `from_raw`/value-reset path).
|
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;
|
- 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.
|
all CRITICAL/HIGH findings fixed.
|
||||||
|
|
||||||
### Left to do (deferred)
|
### Left to do (deferred)
|
||||||
|
|
||||||
- [ ] **BindableColor slots** (`color`, `color_peak`, `fallback_color`,
|
- [x] **BindableColor slots** — CHECKED, decision: keep read-only (won't fix).
|
||||||
`default_color` on color_strip_source) — left non-editable in B1 because
|
Value sources are scalar-only (`ValueStream.get_value() -> float`) and every
|
||||||
scalar-value-source → colour-slot semantics are unclear. **First check:** do
|
colour consumer (`color_strip/single.py`, `effect_stream.py`) reads the static
|
||||||
colour-producing value sources exist? If a value_source can drive a colour,
|
RGB via `bcolor()`, ignoring `source_id`. So a value_source cannot drive a
|
||||||
mark these 4 CONNECTION_MAP entries `bindable: true` (they already validate on
|
colour — wiring `color`/`color_peak`/… would be a dead binding. Documented in
|
||||||
the backend); the write path (`{ color: { source_id } }` → `BindableColor.apply_update`)
|
`api/graph_schema.py` next to the BindableColor entries. (Would only become
|
||||||
is already value-preserving. If not, leave read-only.
|
viable if a colour-producing value-source type is added.)
|
||||||
- [ ] **B4 — delete the frontend `CONNECTION_MAP` duplication.** Blocked on a backend
|
- [~] **B4 — delete the frontend `CONNECTION_MAP` duplication.**
|
||||||
write endpoint. Plan:
|
- [x] **Foundation done:** the backend schema now carries an authoritative
|
||||||
1. Add `PUT /api/v1/graph/connection` (body: target_kind/id, field, source_id)
|
`editable` flag per field (`is_editable()` in `api/graph_schema.py`, mirroring
|
||||||
that validates (reuse `validate_connection`) then APPLIES the write
|
the frontend `_isEditable`: top-level refs + single-level BindableFloat slots;
|
||||||
server-side — top-level via the owning store's update; single-level bindable
|
NOT colour/list/double-nested). `validate-connection` is hardened to reject any
|
||||||
via `apply_update`. Must reuse each entity's existing update path (validation,
|
non-editable field (was list-only). `editable` is surfaced in `/graph/schema`.
|
||||||
factory reconstruction, entity_changed event) — do NOT hand-roll per-store
|
- [ ] **Remaining (the refactor):** frontend fetches `/graph/schema` on load and
|
||||||
mutation. Highest regression risk; needs per-kind tests.
|
derives connection metadata + edges from it (port the `extract_refs` dot-path/list
|
||||||
2. Switch frontend `updateConnection`/`detachConnection` to call it.
|
grammar to TS), keeping only a tiny `kind → {endpoint, cache}` write-routing table;
|
||||||
3. Have the frontend fetch `/graph/schema` and build ports/edges from it,
|
then delete the field-level `CONNECTION_MAP` + the `buildGraph` edge loops
|
||||||
then delete `CONNECTION_MAP` + the buildGraph edge duplication
|
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist in
|
||||||
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist
|
`contexts/graph-editor.md`. **A backend apply-write endpoint is NOT required** —
|
||||||
in `contexts/graph-editor.md`.
|
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
|
- [ ] **D6 — blueprint import/instantiate.** Export exists; the apply half (serialize
|
||||||
a selected subgraph's topology + entities, re-import with id remapping, conflict
|
a selected subgraph's topology + entities, re-import with id remapping, conflict
|
||||||
handling) is large and data-integrity-sensitive (see Data Migration Policy in
|
handling) is large and data-integrity-sensitive (see Data Migration Policy in
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
||||||
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
||||||
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
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) ──
|
# ── Color strip sources (top-level) ──
|
||||||
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
||||||
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
||||||
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
# ── Color strip sources (BindableColor value bindings) ──
|
# ── 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(
|
ConnectionField(
|
||||||
"color_strip_source",
|
"color_strip_source",
|
||||||
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
# ── Output targets ──
|
# ── Output targets ──
|
||||||
ConnectionField("output_target", "device_id", "device", "device"),
|
ConnectionField("output_target", "device_id", "device", "device"),
|
||||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||||
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
|
|
||||||
ConnectionField(
|
ConnectionField(
|
||||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
"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]
|
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]]:
|
def schema_as_dicts() -> list[dict[str, Any]]:
|
||||||
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
||||||
return [
|
return [
|
||||||
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
|
|||||||
"bindable": c.bindable,
|
"bindable": c.bindable,
|
||||||
"nested": c.nested,
|
"nested": c.nested,
|
||||||
"is_list": c.is_list,
|
"is_list": c.is_list,
|
||||||
|
"editable": is_editable(c),
|
||||||
}
|
}
|
||||||
for c in CONNECTION_SCHEMA
|
for c in CONNECTION_SCHEMA
|
||||||
]
|
]
|
||||||
@@ -269,6 +304,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
|
|||||||
return {}
|
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 ───────────────────────────────────────────────────
|
# ── Topology / validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -480,12 +541,11 @@ def validate_connection(
|
|||||||
)
|
)
|
||||||
if cf is None:
|
if cf is None:
|
||||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||||
if cf.is_list:
|
if not is_editable(cf):
|
||||||
# List slots (layers/zones/scene targets) hold many edges sharing the
|
# List slots (need an element index), double-nested fields, and dead
|
||||||
# same (to, field); without an element index this endpoint can't model
|
# colour bindings can't be wired from the graph — edit via the entity
|
||||||
# which one is being replaced for the cycle check. Edit those via the
|
# editor instead.
|
||||||
# entity editor.
|
return False, f"Field '{field}' is not editable via the graph"
|
||||||
return False, f"List connection '{field}' must be edited via the entity editor"
|
|
||||||
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
||||||
return False, f"Target entity not found: {target_id}"
|
return False, f"Target entity not found: {target_id}"
|
||||||
if not source_id:
|
if not source_id:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from ledgrab.api.graph_schema import (
|
|||||||
build_topology,
|
build_topology,
|
||||||
find_dependents,
|
find_dependents,
|
||||||
schema_as_dicts,
|
schema_as_dicts,
|
||||||
serialize_entity,
|
serialize_entity_for_graph,
|
||||||
validate_connection,
|
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)
|
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||||
out[kind] = []
|
out[kind] = []
|
||||||
continue
|
continue
|
||||||
out[kind] = [serialize_entity(m) for m in models]
|
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||||
} from './state.ts';
|
} from './state.ts';
|
||||||
|
import { logError } from './log.ts';
|
||||||
|
|
||||||
/** Result of the backend pre-write connection validator. */
|
/** Result of the backend pre-write connection validator. */
|
||||||
export interface ConnectionValidation {
|
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 ────────────────────────────────────────────────────── */
|
/* ── Types ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ConnectionEntry {
|
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: '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: '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 },
|
{ 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
|
// Output targets
|
||||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ 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: '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: '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
|
// Automations
|
||||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
{ 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));
|
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.
|
* Update a connection: set the reference field on the target entity.
|
||||||
* @param {string} targetId - The target entity's ID
|
* @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.split('.')[0]]: { source_id: newSourceId || '' } }
|
||||||
: { [field]: 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 {
|
try {
|
||||||
await apiPut(url, body);
|
await apiPut(url, body);
|
||||||
// Invalidate the relevant cache so data refreshes
|
// 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, '');
|
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 };
|
export { CONNECTION_MAP };
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface GraphEdge {
|
|||||||
type: string;
|
type: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
editable?: boolean;
|
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;
|
points?: { x: number; y: number }[] | null;
|
||||||
fromNode?: GraphNodeRect;
|
fromNode?: GraphNodeRect;
|
||||||
toNode?: GraphNodeRect;
|
toNode?: GraphNodeRect;
|
||||||
@@ -124,6 +127,12 @@ function _renderEdge(edge: GraphEdge): SVGElement {
|
|||||||
'data-to': to,
|
'data-to': to,
|
||||||
'data-field': field || '',
|
'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
|
// Tooltip
|
||||||
const title = svgEl('title');
|
const title = svgEl('title');
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ interface LayoutEdge {
|
|||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
editable: boolean;
|
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 {
|
interface LayoutResult {
|
||||||
@@ -236,7 +240,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
nodeByIdLocal.set(id, node);
|
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;
|
if (!from || !to) return;
|
||||||
// The referrer (`to`) is always a current entity in these loops; if the
|
// The referrer (`to`) is always a current entity in these loops; if the
|
||||||
// referenced entity (`from`) is missing, the reference is dangling —
|
// 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
|
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||||
const editable = !field.includes('.');
|
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
|
// 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 || []) {
|
for (const s of e.valueSources || []) {
|
||||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
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');
|
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
|
// 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.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');
|
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) {
|
if (s.layers) {
|
||||||
for (const layer of s.layers) {
|
s.layers.forEach((layer: any, i: number) => {
|
||||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
|
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');
|
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) {
|
if (s.zones) {
|
||||||
for (const zone of s.zones) {
|
s.zones.forEach((zone: any, i: number) => {
|
||||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
|
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id', '', { list: 'zones', index: i, ref: 'source_id' });
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advanced picture calibration lines
|
// 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');
|
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||||
const transVsId = bindableSourceId(t.transition);
|
const transVsId = bindableSourceId(t.transition);
|
||||||
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
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
|
// KC target settings
|
||||||
if (t.settings) {
|
if (t.settings) {
|
||||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
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) {
|
for (const [id, msgs] of issues) {
|
||||||
if (!msgs.length) continue;
|
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;
|
if (!el) continue;
|
||||||
el.classList.add('has-issue');
|
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 { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import { t } from '../core/i18n.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 { showTypePicker } from '../core/icon-select.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.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);
|
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 {
|
try {
|
||||||
const entities = await _fetchAllEntities();
|
const entities = await _fetchAllEntities();
|
||||||
// Index raw entities by id for subtype-safe bindable-slot resolution.
|
// 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)
|
* Keep only the slots the target entity actually exposes (subtype-safe) — a
|
||||||
* — e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
* field is offered iff its first path segment is a key on the serialized entity.
|
||||||
* `smoothing`. Non-bindable matches always pass through.
|
* 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);
|
const ent = _entitiesById.get(targetId);
|
||||||
return matches.filter(m => {
|
if (!ent) return matches; // no data (e.g. freshly created) → don't over-filter
|
||||||
if (!m.bindable || !ent) return true;
|
// Offer a field only if the target entity actually exposes its slot (its
|
||||||
return m.field.split('.')[0] in ent;
|
// 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. */
|
/** 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();
|
_dismissEdgeContextMenu();
|
||||||
|
|
||||||
const field = edgePath.getAttribute('data-field') || '';
|
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
|
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
||||||
|
|
||||||
const toId = edgePath.getAttribute('data-to') ?? '';
|
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> {
|
async function _detachSelectedEdge(): Promise<void> {
|
||||||
if (!_selectedEdge) return;
|
if (!_selectedEdge) return;
|
||||||
const { from, to, field, targetKind } = _selectedEdge;
|
const { from, to, field, targetKind } = _selectedEdge;
|
||||||
|
|||||||
@@ -2590,7 +2590,10 @@
|
|||||||
"graph.action.connect": "Connect",
|
"graph.action.connect": "Connect",
|
||||||
"graph.action.disconnect": "Disconnect",
|
"graph.action.disconnect": "Disconnect",
|
||||||
"graph.action.move": "Move node",
|
"graph.action.move": "Move node",
|
||||||
|
"graph.action.rewire": "Re-wire slot",
|
||||||
"graph.choose_connection": "Choose connection",
|
"graph.choose_connection": "Choose connection",
|
||||||
|
"graph.rewire": "Re-wire…",
|
||||||
|
"graph.rewire_choose_source": "Choose a new source",
|
||||||
"graph.issues": "Issues",
|
"graph.issues": "Issues",
|
||||||
"graph.issues_none": "No issues found",
|
"graph.issues_none": "No issues found",
|
||||||
"graph.issue.broken_ref": "Broken reference: {field}",
|
"graph.issue.broken_ref": "Broken reference: {field}",
|
||||||
|
|||||||
@@ -2272,7 +2272,10 @@
|
|||||||
"graph.action.connect": "Соединить",
|
"graph.action.connect": "Соединить",
|
||||||
"graph.action.disconnect": "Отсоединить",
|
"graph.action.disconnect": "Отсоединить",
|
||||||
"graph.action.move": "Переместить узел",
|
"graph.action.move": "Переместить узел",
|
||||||
|
"graph.action.rewire": "Переподключить слот",
|
||||||
"graph.choose_connection": "Выберите соединение",
|
"graph.choose_connection": "Выберите соединение",
|
||||||
|
"graph.rewire": "Переподключить…",
|
||||||
|
"graph.rewire_choose_source": "Выберите новый источник",
|
||||||
"graph.issues": "Проблемы",
|
"graph.issues": "Проблемы",
|
||||||
"graph.issues_none": "Проблем не найдено",
|
"graph.issues_none": "Проблем не найдено",
|
||||||
"graph.issue.broken_ref": "Битая ссылка: {field}",
|
"graph.issue.broken_ref": "Битая ссылка: {field}",
|
||||||
|
|||||||
@@ -2268,7 +2268,10 @@
|
|||||||
"graph.action.connect": "连接",
|
"graph.action.connect": "连接",
|
||||||
"graph.action.disconnect": "断开连接",
|
"graph.action.disconnect": "断开连接",
|
||||||
"graph.action.move": "移动节点",
|
"graph.action.move": "移动节点",
|
||||||
|
"graph.action.rewire": "重新连接槽位",
|
||||||
"graph.choose_connection": "选择连接",
|
"graph.choose_connection": "选择连接",
|
||||||
|
"graph.rewire": "重新连接…",
|
||||||
|
"graph.rewire_choose_source": "选择新的来源",
|
||||||
"graph.issues": "问题",
|
"graph.issues": "问题",
|
||||||
"graph.issues_none": "未发现问题",
|
"graph.issues_none": "未发现问题",
|
||||||
"graph.issue.broken_ref": "无效引用:{field}",
|
"graph.issue.broken_ref": "无效引用:{field}",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ These exercise reference extraction, topology building, dependents, cycle and
|
|||||||
dangling-reference detection without booting the app or any store.
|
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 (
|
from ledgrab.api.graph_schema import (
|
||||||
CONNECTION_SCHEMA,
|
CONNECTION_SCHEMA,
|
||||||
ENTITY_KINDS,
|
ENTITY_KINDS,
|
||||||
@@ -11,7 +15,10 @@ from ledgrab.api.graph_schema import (
|
|||||||
detect_cycles,
|
detect_cycles,
|
||||||
extract_refs,
|
extract_refs,
|
||||||
find_dependents,
|
find_dependents,
|
||||||
|
graph_field_roots,
|
||||||
|
is_editable,
|
||||||
schema_for_kind,
|
schema_for_kind,
|
||||||
|
serialize_entity_for_graph,
|
||||||
validate_connection,
|
validate_connection,
|
||||||
would_create_cycle,
|
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"
|
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
|
||||||
)
|
)
|
||||||
assert ok is False
|
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():
|
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
|
||||||
Reference in New Issue
Block a user