Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done nothing. The canvas pulsed on the message's timestamp alone, and a message has no idea who published it — so it credited whichever node happened to be drawn as a producer. That was never only about dashboards. Two nodes producing one message pulsed both their edges whichever fired, and a message produced in another flow changed with nothing on screen to account for it at all. Values now carry their cause: a node, a dashboard widget, another flow, an agent or an API caller. An edge pulses only for the producer that actually published, and the edge inspector says where a value came from when it did not come from a node. What is not a node in this flow is now drawn as one — a label rather than a card, because a dashboard with twenty tiles would otherwise bury the logic the canvas exists to show. That covers cross-flow wiring too, which is the link in/out affordance that has been missing. They are never part of the document. They join at render, after everything that reads or writes the canvas nodes, so an autosave, an undo or a delete cannot reach them — with a Playwright test that drags a node and asserts the stored flow still holds exactly what it did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
+18
-1
@@ -8,9 +8,19 @@ Deferring because out of scope is fine, but don't mention deferring than.
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
- FEAT/UX: unify the layout between dashboard and flows: 1) In the flows: adapt the "overview" from the dashboard; clicking flows in the sidebar leads to a searchable overview of flows like in the dashboard. Then the "flowbar" get's merged into the bottom toolbar (status and edit button; add flow button goes in the overview) with the name of the current flow still being displayed at the top for reference. 2) adopt the floating bottom toolbar from the flow viewport and the click widget to edit behavior (make sure inputs like sliders and buttons stay interactable). Also adapt the new flow-title (sync UI) and the new edit button in the toolbar to trigger a panel for renaming the flow (and more later). The same floating right panel as in the flows should also be the way to go to edit a widget. Then in the toolbar should be a "View" button which opens the dashboard in a new browser tab (renders without the sidebar or any edit option) with a link to be used in a wallpanel later
|
||||||
|
- FEAT/UI: allow resizing of dashboard widgets by dragging edges and add the same dotted background we see in the flows viewport (again, sync UI (see above))
|
||||||
|
- FEAT/UI: add a graph widget to the dashboard
|
||||||
|
- BUG/UI: strictly type a dashboard widget; i.e. wiring a boolean switch to a float message (or wiring no publish/consume at all) should flag the node as error -> reuse error indicator from the flows
|
||||||
|
- FEAt/UX: allow setting a grid size for the dashboard to adjust to different wallpanel sizes (goes into the new dashboard panel setting; see above)
|
||||||
|
- FEAT/UX: allow copy-pasting of nodes (also between flows)
|
||||||
|
- FEAT/UX: introduce general keyboard shortcut system. As a first test: when hitting ctrl-s we should catch this keyboard shortcut to a) publish a flow b) save (and apply) code changes when in the code editor
|
||||||
|
- FEAT/UX: in delay and schedule nodes: help creating cron schedules by providing a legend; Also: if interval is entered, this should automatically create a cron according to the interval set
|
||||||
|
- FEAT/UX: python function should automatically update according to what the node consumes or produces
|
||||||
|
- BUG/UI: there are two places to rename a flow; we should only go for the one in the top of the panel name (so that the UX is identical to the ones from the nodes). The actual rename should happen with dedicated "confirm" button (same for the nodes)
|
||||||
- BUG/UI: when the edges pulse on message, they change their color (go from slightly darker back to normal) at the end again. Double check that after the animation they end up with the same color as before to not have this double-pulse effect
|
- BUG/UI: when the edges pulse on message, they change their color (go from slightly darker back to normal) at the end again. Double check that after the animation they end up with the same color as before to not have this double-pulse effect
|
||||||
- BUG/UI: enlarge the icon in the sidebar slightly
|
|
||||||
- BUG/UI: clicking outside the panel does not discard the flow edit panel
|
- BUG/UI: clicking outside the panel does not discard the flow edit panel
|
||||||
|
- FEAT/UX: for the same type of node show suggestions in the setting fields based on other nodes; I.e. if I create an mqtt node and I already configured one in another flow, allow selecting the values of the exiting mqtt node as a drop-down option (identical to consumables) when clicking inside the corresponding field in the new node (same for all other nodes)
|
||||||
- BUG/UI: the graph showed in the node edit panel should also be shown for a specific edge inside the pop-up panel when clicking the edge
|
- BUG/UI: the graph showed in the node edit panel should also be shown for a specific edge inside the pop-up panel when clicking the edge
|
||||||
- FEAT/FLOW: single-stepping a paused flow. The work queue it needed now exists: pausing
|
- FEAT/FLOW: single-stepping a paused flow. The work queue it needed now exists: pausing
|
||||||
parks claimed items per flow and resuming drains them, so a step button is a matter of
|
parks claimed items per flow and resuming drains them, so a step button is a matter of
|
||||||
@@ -96,6 +106,13 @@ Deferring because out of scope is fine, but don't mention deferring than.
|
|||||||
mode bits hold a value outside the known set. Faithful to the reference decoder, but
|
mode bits hold a value outside the known set. Faithful to the reference decoder, but
|
||||||
"off" would read better.
|
"off" would read better.
|
||||||
|
|
||||||
|
- FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a
|
||||||
|
node that sits between the lane and the node it wires to. Readable, but a routed edge
|
||||||
|
would be tidier.
|
||||||
|
- FEAT/UI: endpoints are always shown. A flow read by a twenty-tile wall panel gets twenty
|
||||||
|
labels, and only the input ones affect the graph — dimming or collapsing consumers is
|
||||||
|
the obvious next move if it turns out busy.
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
|
|
||||||
- FEAT/UI: a "Bug" icon on the node error bubble opening the logs panel at that node's
|
- FEAT/UI: a "Bug" icon on the node error bubble opening the logs panel at that node's
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str
|
|||||||
- [x] Tab-style view of atomic flows, with a floating dock
|
- [x] Tab-style view of atomic flows, with a floating dock
|
||||||
- [x] Embedded code editor (Monaco) for node source
|
- [x] Embedded code editor (Monaco) for node source
|
||||||
- [x] Live values on the edges, with the last payload and its time on click
|
- [x] Live values on the edges, with the last payload and its time on click
|
||||||
|
- [x] Provenance: every value says what caused it, so an edge pulses for the producer
|
||||||
|
that actually published rather than every producer of that message. A dashboard
|
||||||
|
control, another flow or an API caller is drawn as a label on the canvas instead
|
||||||
|
of being invisible — which also gives cross-flow wiring the link in/out it lacked
|
||||||
- [x] Validation shown on the node it belongs to, and summarised in the dock
|
- [x] Validation shown on the node it belongs to, and summarised in the dock
|
||||||
- [x] Publish control and draft markers in the flow bar, discard in the flow
|
- [x] Publish control and draft markers in the flow bar, discard in the flow
|
||||||
panel, and a conflict dialog when another client got there first
|
panel, and a conflict dialog when another client got there first
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlmodel import Session
|
|||||||
from app.api.deps import FlowControllerDep, get_current_user, user_from_token
|
from app.api.deps import FlowControllerDep, get_current_user, user_from_token
|
||||||
from app.core.db import engine
|
from app.core.db import engine
|
||||||
from app.flow.controller import FlowController
|
from app.flow.controller import FlowController
|
||||||
|
from app.flow.dashboards import DashboardStore
|
||||||
from app.flow.events import event_bus
|
from app.flow.events import event_bus
|
||||||
from app.flow.messages import qualify
|
from app.flow.messages import qualify
|
||||||
from app.flow.pipeline import ValidationIssue
|
from app.flow.pipeline import ValidationIssue
|
||||||
@@ -52,6 +53,27 @@ router = APIRouter(
|
|||||||
ws_router = APIRouter(prefix="/flows", tags=["flows"])
|
ws_router = APIRouter(prefix="/flows", tags=["flows"])
|
||||||
|
|
||||||
|
|
||||||
|
class Endpoint(BaseModel):
|
||||||
|
"""Something wired into this flow that is not a node in it.
|
||||||
|
|
||||||
|
A dashboard control setting one of its messages, a tile showing one, or a
|
||||||
|
node in another flow on the far side of a dotted name. The canvas draws
|
||||||
|
these so a value never appears to come from nowhere — or worse, appears to
|
||||||
|
come from whichever node happens to be drawn as a producer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: dashboard or flow.
|
||||||
|
kind: str
|
||||||
|
#: Stable within its kind, and used as the canvas node id.
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
#: What sort of widget, or the node type in the other flow.
|
||||||
|
detail: str = ""
|
||||||
|
#: Messages of this flow it publishes, and ones it reads.
|
||||||
|
provides: list[str] = []
|
||||||
|
requires: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class FlowDetail(BaseModel):
|
class FlowDetail(BaseModel):
|
||||||
"""A flow plus how it is currently doing.
|
"""A flow plus how it is currently doing.
|
||||||
|
|
||||||
@@ -66,6 +88,8 @@ class FlowDetail(BaseModel):
|
|||||||
has_draft: bool = False
|
has_draft: bool = False
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
#: Dashboards and other flows wired into this one.
|
||||||
|
endpoints: list[Endpoint] = []
|
||||||
|
|
||||||
|
|
||||||
class ValidationResult(BaseModel):
|
class ValidationResult(BaseModel):
|
||||||
@@ -92,12 +116,45 @@ class TriggerRequest(BaseModel):
|
|||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoints(controller: FlowController, flow: str) -> list[Endpoint]:
|
||||||
|
"""Everything wired into ``flow`` from outside it."""
|
||||||
|
found: list[Endpoint] = []
|
||||||
|
|
||||||
|
dashboards: DashboardStore | None = getattr(controller, "dashboards", None)
|
||||||
|
if dashboards is not None:
|
||||||
|
for binding in dashboards.bindings_for(flow):
|
||||||
|
found.append(
|
||||||
|
Endpoint(
|
||||||
|
kind="dashboard",
|
||||||
|
id=f"dashboard:{binding['dashboard']}:{binding['widget']}",
|
||||||
|
label=binding["title"],
|
||||||
|
detail=binding["type"],
|
||||||
|
provides=[binding["provides"]] if binding["provides"] else [],
|
||||||
|
requires=binding["requires"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for other, node_id, provides, requires in controller.cross_flow_nodes(flow):
|
||||||
|
found.append(
|
||||||
|
Endpoint(
|
||||||
|
kind="flow",
|
||||||
|
id=f"flow:{other}.{node_id}",
|
||||||
|
label=f"{other}.{node_id}",
|
||||||
|
detail="flow",
|
||||||
|
provides=provides,
|
||||||
|
requires=requires,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
||||||
name = definition.name
|
name = definition.name
|
||||||
running = {
|
running = {
|
||||||
"enabled": controller.is_enabled(name),
|
"enabled": controller.is_enabled(name),
|
||||||
"paused": controller.is_paused(name),
|
"paused": controller.is_paused(name),
|
||||||
}
|
}
|
||||||
|
endpoints = _endpoints(controller, name)
|
||||||
if controller.store.has_draft(name):
|
if controller.store.has_draft(name):
|
||||||
# Report the draft the editor is showing, not the version running
|
# Report the draft the editor is showing, not the version running
|
||||||
# underneath it — otherwise a node the author just broke looks fine.
|
# underneath it — otherwise a node the author just broke looks fine.
|
||||||
@@ -107,12 +164,14 @@ def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
|||||||
nodes=preview.nodes,
|
nodes=preview.nodes,
|
||||||
issues=preview.issues,
|
issues=preview.issues,
|
||||||
has_draft=True,
|
has_draft=True,
|
||||||
|
endpoints=endpoints,
|
||||||
**running,
|
**running,
|
||||||
)
|
)
|
||||||
return FlowDetail(
|
return FlowDetail(
|
||||||
definition=definition,
|
definition=definition,
|
||||||
nodes=controller.node_statuses(name),
|
nodes=controller.node_statuses(name),
|
||||||
issues=controller.flow_issues(name),
|
issues=controller.flow_issues(name),
|
||||||
|
endpoints=endpoints,
|
||||||
**running,
|
**running,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.api.deps import FlowControllerDep, get_current_user
|
from app.api.deps import FlowControllerDep, get_current_user
|
||||||
from app.flow.messages import flow_of
|
from app.flow.messages import flow_of
|
||||||
|
from app.flow.pipeline import ValueSource
|
||||||
from app.flow.state import as_number
|
from app.flow.state import as_number
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -43,6 +44,12 @@ class MessagesPublic(BaseModel):
|
|||||||
|
|
||||||
class PublishRequest(BaseModel):
|
class PublishRequest(BaseModel):
|
||||||
value: Any
|
value: Any
|
||||||
|
#: Where this came from, so the canvas can show it arriving from outside
|
||||||
|
#: rather than crediting whichever node is drawn as a producer.
|
||||||
|
source_kind: str = "api"
|
||||||
|
source_id: str = ""
|
||||||
|
source_label: str = ""
|
||||||
|
source_detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
class MessageValue(BaseModel):
|
class MessageValue(BaseModel):
|
||||||
@@ -73,8 +80,16 @@ async def publish_message(
|
|||||||
Only a message some flow declares can be published to: flows own the
|
Only a message some flow declares can be published to: flows own the
|
||||||
namespace, and a dashboard is a client of it rather than a second author.
|
namespace, and a dashboard is a client of it rather than a second author.
|
||||||
"""
|
"""
|
||||||
|
source = ValueSource(
|
||||||
|
kind=body.source_kind,
|
||||||
|
id=body.source_id,
|
||||||
|
label=body.source_label or body.source_id or "API",
|
||||||
|
detail=body.source_detail,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await run_in_threadpool(controller.publish_message, name, body.value)
|
await run_in_threadpool(
|
||||||
|
controller.publish_message, name, body.value, source
|
||||||
|
)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"No flow declares a message named '{name}'"
|
status_code=404, detail=f"No flow declares a message named '{name}'"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from app.flow.nodes import (
|
|||||||
SwitchNode,
|
SwitchNode,
|
||||||
TriggerNode,
|
TriggerNode,
|
||||||
)
|
)
|
||||||
from app.flow.pipeline import Pipeline, ValidationIssue
|
from app.flow.pipeline import Pipeline, ValidationIssue, ValueSource
|
||||||
from app.flow.schemas import (
|
from app.flow.schemas import (
|
||||||
FlowDef,
|
FlowDef,
|
||||||
Health,
|
Health,
|
||||||
@@ -276,6 +276,9 @@ class FlowController:
|
|||||||
# Without one, every trigger runs inline where it was raised.
|
# Without one, every trigger runs inline where it was raised.
|
||||||
self.execution = execution
|
self.execution = execution
|
||||||
self.alerts = alerts
|
self.alerts = alerts
|
||||||
|
# Set by the lifespan once the store exists; the canvas asks the
|
||||||
|
# controller which dashboards are wired into a flow.
|
||||||
|
self.dashboards: Any = None
|
||||||
|
|
||||||
self.pipeline: Pipeline | None = None
|
self.pipeline: Pipeline | None = None
|
||||||
self.loaded: dict[str, LoadedNode] = {}
|
self.loaded: dict[str, LoadedNode] = {}
|
||||||
@@ -648,7 +651,38 @@ class FlowController:
|
|||||||
)
|
)
|
||||||
return infos
|
return infos
|
||||||
|
|
||||||
def publish_message(self, name: str, value: Any) -> None:
|
def cross_flow_nodes(
|
||||||
|
self, flow: str
|
||||||
|
) -> list[tuple[str, str, list[str], list[str]]]:
|
||||||
|
"""Nodes in other flows on the far side of a dotted message name.
|
||||||
|
|
||||||
|
Returns ``(flow, node id, messages of `flow` it provides, ones it
|
||||||
|
requires)``. This is what link-in and link-out mean: the wiring is
|
||||||
|
already real — the canvas simply had no way to draw the other end.
|
||||||
|
"""
|
||||||
|
prefix = f"{flow}."
|
||||||
|
found = []
|
||||||
|
for other in self.store.read_all():
|
||||||
|
if other.name == flow:
|
||||||
|
continue
|
||||||
|
for node in other.nodes:
|
||||||
|
provides = sorted(
|
||||||
|
qualify(other.name, spec.name)
|
||||||
|
for spec in _bound(node.provides)
|
||||||
|
if qualify(other.name, spec.name).startswith(prefix)
|
||||||
|
)
|
||||||
|
requires = sorted(
|
||||||
|
qualify(other.name, spec.name)
|
||||||
|
for spec in _bound(node.requires)
|
||||||
|
if qualify(other.name, spec.name).startswith(prefix)
|
||||||
|
)
|
||||||
|
if provides or requires:
|
||||||
|
found.append((other.name, node.id, provides, requires))
|
||||||
|
return found
|
||||||
|
|
||||||
|
def publish_message(
|
||||||
|
self, name: str, value: Any, source: ValueSource | None = None
|
||||||
|
) -> None:
|
||||||
"""Put a value into the graph from outside. Blocking.
|
"""Put a value into the graph from outside. Blocking.
|
||||||
|
|
||||||
Refuses a name no flow declares: the flows own the namespace, and a
|
Refuses a name no flow declares: the flows own the namespace, and a
|
||||||
@@ -672,7 +706,7 @@ class FlowController:
|
|||||||
|
|
||||||
# The same check a node's output gets; a dashboard is not looser.
|
# The same check a node's output gets; a dashboard is not looser.
|
||||||
spec.check(value)
|
spec.check(value)
|
||||||
self.pipeline.publish({name: value})
|
self.pipeline.publish({name: value}, source)
|
||||||
|
|
||||||
def queue_stats(self) -> dict[str, Any]:
|
def queue_stats(self) -> dict[str, Any]:
|
||||||
return self.execution.stats() if self.execution is not None else {}
|
return self.execution.stats() if self.execution is not None else {}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ DASHBOARD_DIR = "_dashboards"
|
|||||||
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
||||||
HISTORY_CAP = 5000
|
HISTORY_CAP = 5000
|
||||||
|
|
||||||
|
#: Resolved out here on purpose: the store has a ``list`` method, which
|
||||||
|
#: shadows the builtin for any annotation written inside the class.
|
||||||
|
Bindings = list[dict[str, Any]]
|
||||||
|
|
||||||
WidgetType = Literal[
|
WidgetType = Literal[
|
||||||
# Display
|
# Display
|
||||||
"stat",
|
"stat",
|
||||||
@@ -263,6 +267,40 @@ class DashboardStore:
|
|||||||
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
|
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
|
||||||
return renamed
|
return renamed
|
||||||
|
|
||||||
|
def bindings_for(self, flow: str) -> Bindings:
|
||||||
|
"""Every widget bound to a message of ``flow``.
|
||||||
|
|
||||||
|
What the canvas draws as an endpoint: a control that sets one of this
|
||||||
|
flow's messages, or a tile that shows one. Without this a dashboard is
|
||||||
|
an invisible participant — a value changes and nothing on the canvas
|
||||||
|
accounts for it.
|
||||||
|
"""
|
||||||
|
prefix = f"{flow}."
|
||||||
|
found: Bindings = []
|
||||||
|
for path in sorted(self.root.glob("*/dashboard.json")):
|
||||||
|
try:
|
||||||
|
defn = DashboardDef.model_validate_json(path.read_text())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for widget in defn.widgets:
|
||||||
|
# A control produces the message; a tile consumes it.
|
||||||
|
produces = widget.target if widget.target.startswith(prefix) else ""
|
||||||
|
consumes = [m for m in widget.messages if m.startswith(prefix)]
|
||||||
|
if not produces and not consumes:
|
||||||
|
continue
|
||||||
|
found.append(
|
||||||
|
{
|
||||||
|
"dashboard": defn.name,
|
||||||
|
"dashboard_title": defn.title or defn.name,
|
||||||
|
"widget": widget.id,
|
||||||
|
"title": widget.title or widget.id,
|
||||||
|
"type": widget.type,
|
||||||
|
"provides": produces,
|
||||||
|
"requires": consumes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
def history_requirements(self) -> dict[str, int]:
|
def history_requirements(self) -> dict[str, int]:
|
||||||
"""How many points to keep per message, so charts have a past to draw.
|
"""How many points to keep per message, so charts have a past to draw.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,29 @@ class ValidationIssue(BaseModel):
|
|||||||
message_name: str | None = None
|
message_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValueSource(BaseModel):
|
||||||
|
"""Who caused a message to take its current value.
|
||||||
|
|
||||||
|
The canvas draws an edge per producer, so without this it pulses every one
|
||||||
|
of them and claims a node published something it did not. It is also what
|
||||||
|
lets a value arriving from outside the flow — a dashboard control, another
|
||||||
|
flow, an agent — be shown at all, since none of those is a node here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: node, dashboard, flow, agent or api.
|
||||||
|
kind: str = "node"
|
||||||
|
#: Node id, dashboard name, or whatever identifies the caller.
|
||||||
|
id: str = ""
|
||||||
|
#: What to call it on screen.
|
||||||
|
label: str = ""
|
||||||
|
#: The widget, for a dashboard.
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def node_source(node: Node) -> ValueSource:
|
||||||
|
return ValueSource(kind="node", id=node.id, label=node.local_id)
|
||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
class Pipeline:
|
||||||
"""Directed graph of nodes with automatic dependency resolution."""
|
"""Directed graph of nodes with automatic dependency resolution."""
|
||||||
|
|
||||||
@@ -495,6 +518,7 @@ class Pipeline:
|
|||||||
# Append-only, so it needs no lock of its own.
|
# Append-only, so it needs no lock of its own.
|
||||||
state.append_history(result, ts, self.history_limits)
|
state.append_history(result, ts, self.history_limits)
|
||||||
self._increment_message_versions(result)
|
self._increment_message_versions(result)
|
||||||
|
origin = node_source(node)
|
||||||
for name, value in result.items():
|
for name, value in result.items():
|
||||||
self._publish(
|
self._publish(
|
||||||
{
|
{
|
||||||
@@ -503,6 +527,7 @@ class Pipeline:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"value": value,
|
"value": value,
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
|
"source": origin.model_dump(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -685,6 +710,7 @@ class Pipeline:
|
|||||||
state.update({self._timestamp_key(name): ts for name in outputs})
|
state.update({self._timestamp_key(name): ts for name in outputs})
|
||||||
state.append_history(outputs, ts, self.history_limits)
|
state.append_history(outputs, ts, self.history_limits)
|
||||||
self._increment_message_versions(outputs)
|
self._increment_message_versions(outputs)
|
||||||
|
origin = node_source(node)
|
||||||
for name, value in outputs.items():
|
for name, value in outputs.items():
|
||||||
self._publish(
|
self._publish(
|
||||||
{
|
{
|
||||||
@@ -693,6 +719,7 @@ class Pipeline:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"value": value,
|
"value": value,
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
|
"source": origin.model_dump(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# An injecting node — an MQTT subscriber, a webhook — publishes
|
# An injecting node — an MQTT subscriber, a webhook — publishes
|
||||||
@@ -760,16 +787,24 @@ class Pipeline:
|
|||||||
self.apply_outputs(node, outputs)
|
self.apply_outputs(node, outputs)
|
||||||
return self.run_downstream(node)
|
return self.run_downstream(node)
|
||||||
|
|
||||||
def publish(self, values: dict[str, Any]) -> None:
|
def publish(
|
||||||
|
self, values: dict[str, Any], source: ValueSource | None = None
|
||||||
|
) -> None:
|
||||||
"""Put values into the graph without a node having produced them.
|
"""Put values into the graph without a node having produced them.
|
||||||
|
|
||||||
This is what a dashboard control does: the value is real, it just came
|
This is what a dashboard control does: the value is real, it just came
|
||||||
from a person rather than a sensor. Everything consuming those names
|
from a person rather than a sensor. Everything consuming those names
|
||||||
runs, the same as if a node had published them.
|
runs, the same as if a node had published them.
|
||||||
|
|
||||||
|
``source`` says what did, so the canvas can show the value arriving
|
||||||
|
from outside instead of blaming whichever node happens to be drawn as
|
||||||
|
a producer of that message.
|
||||||
"""
|
"""
|
||||||
if not values:
|
if not values:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
origin = source or ValueSource(kind="api", label="API")
|
||||||
|
|
||||||
ts = time.time()
|
ts = time.time()
|
||||||
with self._state.lock():
|
with self._state.lock():
|
||||||
self._state.update(values)
|
self._state.update(values)
|
||||||
@@ -784,6 +819,7 @@ class Pipeline:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"value": value,
|
"value": value,
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
|
"source": origin.model_dump(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app.state.flow_controller = controller
|
app.state.flow_controller = controller
|
||||||
dashboards = DashboardStore(controller.store)
|
dashboards = DashboardStore(controller.store)
|
||||||
app.state.dashboard_store = dashboards
|
app.state.dashboard_store = dashboards
|
||||||
|
controller.dashboards = dashboards
|
||||||
# Charts need a deeper series than the default; tell the engine
|
# Charts need a deeper series than the default; tell the engine
|
||||||
# before it starts recording.
|
# before it starts recording.
|
||||||
controller.set_history_limits(dashboards.history_requirements())
|
controller.set_history_limits(dashboards.history_requirements())
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Who caused a value.
|
||||||
|
|
||||||
|
The canvas draws an edge per producer of a message. Without knowing which one
|
||||||
|
actually published, it pulses all of them — and when the cause is a dashboard
|
||||||
|
control or another flow, it pulses a node that did nothing at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.flow.dashboards import (
|
||||||
|
DashboardDef,
|
||||||
|
DashboardStore,
|
||||||
|
PageDef,
|
||||||
|
SectionDef,
|
||||||
|
WidgetDef,
|
||||||
|
)
|
||||||
|
from app.flow.events import EventBus
|
||||||
|
from app.flow.messages import DType, MessageSpec
|
||||||
|
from app.flow.nodes import Node
|
||||||
|
from app.flow.pipeline import Pipeline, ValueSource
|
||||||
|
from app.flow.state import MemoryState
|
||||||
|
from app.flow.store import FlowStore
|
||||||
|
|
||||||
|
|
||||||
|
def collect(bus: EventBus) -> list[dict]:
|
||||||
|
events: list[dict] = []
|
||||||
|
bus.publish = events.append # type: ignore[method-assign]
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def temp_node() -> Node:
|
||||||
|
node = Node(
|
||||||
|
f=lambda params: {"temp": 21.0},
|
||||||
|
provides=[MessageSpec(name="temp", port="temp", dtype=DType.FLOAT)],
|
||||||
|
name="sensor",
|
||||||
|
)
|
||||||
|
node.assign_flow("house", "sensor")
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_a_node_produced_names_that_node():
|
||||||
|
bus = EventBus()
|
||||||
|
events = collect(bus)
|
||||||
|
node = temp_node()
|
||||||
|
pipeline = Pipeline(nodes=[node], state=MemoryState(), events=bus)
|
||||||
|
|
||||||
|
pipeline.run()
|
||||||
|
|
||||||
|
published = [e for e in events if e["type"] == "message_value"]
|
||||||
|
assert published[0]["source"] == {
|
||||||
|
"kind": "node",
|
||||||
|
"id": "house.sensor",
|
||||||
|
"label": "sensor",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_a_node_injected_names_that_node():
|
||||||
|
"""An MQTT message or a webhook arrives this way rather than by executing."""
|
||||||
|
bus = EventBus()
|
||||||
|
events = collect(bus)
|
||||||
|
node = temp_node()
|
||||||
|
pipeline = Pipeline(nodes=[node], state=MemoryState(), events=bus)
|
||||||
|
|
||||||
|
pipeline.apply_outputs(node, {"house.temp": 19.0})
|
||||||
|
|
||||||
|
published = [e for e in events if e["type"] == "message_value"]
|
||||||
|
assert published[0]["source"]["id"] == "house.sensor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_from_a_dashboard_says_so_rather_than_blaming_a_node():
|
||||||
|
"""The bug this exists for: a slider must not light up a node's edge."""
|
||||||
|
bus = EventBus()
|
||||||
|
events = collect(bus)
|
||||||
|
pipeline = Pipeline(nodes=[temp_node()], state=MemoryState(), events=bus)
|
||||||
|
|
||||||
|
pipeline.publish(
|
||||||
|
{"house.temp": 25.0},
|
||||||
|
ValueSource(
|
||||||
|
kind="dashboard", id="panel", label="Setpoint", detail="slider"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
published = [e for e in events if e["type"] == "message_value"]
|
||||||
|
assert published[0]["source"]["kind"] == "dashboard"
|
||||||
|
assert published[0]["source"]["label"] == "Setpoint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_value_from_nowhere_in_particular_is_still_attributed():
|
||||||
|
bus = EventBus()
|
||||||
|
events = collect(bus)
|
||||||
|
pipeline = Pipeline(nodes=[temp_node()], state=MemoryState(), events=bus)
|
||||||
|
|
||||||
|
pipeline.publish({"house.temp": 25.0})
|
||||||
|
|
||||||
|
published = [e for e in events if e["type"] == "message_value"]
|
||||||
|
assert published[0]["source"]["kind"] == "api"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What the canvas draws for it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_widgets_wired_into_a_flow_are_reported(tmp_path):
|
||||||
|
store = DashboardStore(FlowStore(tmp_path / "flows"))
|
||||||
|
store.write(
|
||||||
|
DashboardDef(
|
||||||
|
name="panel",
|
||||||
|
title="Panel",
|
||||||
|
pages=[
|
||||||
|
PageDef(
|
||||||
|
id="main",
|
||||||
|
sections=[
|
||||||
|
SectionDef(
|
||||||
|
id="main",
|
||||||
|
widgets=[
|
||||||
|
WidgetDef(
|
||||||
|
id="setpoint",
|
||||||
|
type="slider",
|
||||||
|
title="Setpoint",
|
||||||
|
config={"target": "house.setpoint"},
|
||||||
|
),
|
||||||
|
WidgetDef(
|
||||||
|
id="reading",
|
||||||
|
type="stat",
|
||||||
|
title="Reading",
|
||||||
|
config={"message": "house.temp"},
|
||||||
|
),
|
||||||
|
# Another flow's message: not this flow's business.
|
||||||
|
WidgetDef(
|
||||||
|
id="elsewhere",
|
||||||
|
type="stat",
|
||||||
|
config={"message": "garage.temp"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bindings = store.bindings_for("house")
|
||||||
|
|
||||||
|
assert [b["widget"] for b in bindings] == ["setpoint", "reading"]
|
||||||
|
setpoint = bindings[0]
|
||||||
|
assert setpoint["provides"] == "house.setpoint"
|
||||||
|
assert setpoint["requires"] == []
|
||||||
|
assert bindings[1]["requires"] == ["house.temp"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flow_nothing_points_at_has_no_endpoints(tmp_path):
|
||||||
|
store = DashboardStore(FlowStore(tmp_path / "flows"))
|
||||||
|
|
||||||
|
assert store.bindings_for("house") == []
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** The reported bug, end to end: move the real slider, watch the canvas. */
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import { chromium } from "@playwright/test"
|
||||||
|
|
||||||
|
const APP_URL = process.env.APP_URL || "http://app.localhost"
|
||||||
|
const OUT = process.env.SCREENSHOT_DIR || "screenshots"
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
|
||||||
|
await page.getByTestId("email-input").fill(process.env.FIRST_SUPERUSER)
|
||||||
|
await page
|
||||||
|
.getByTestId("password-input")
|
||||||
|
.fill(process.env.FIRST_SUPERUSER_PASSWORD)
|
||||||
|
await page.getByRole("button", { name: /log in/i }).click()
|
||||||
|
await page.waitForURL(`${APP_URL}/`, { timeout: 15000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const theme of ["light", "dark"]) {
|
||||||
|
const dir = `${OUT}/${theme}`
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
colorScheme: theme,
|
||||||
|
})
|
||||||
|
await context.addInitScript((t) => {
|
||||||
|
localStorage.setItem("fluksio-ui-theme", t)
|
||||||
|
}, theme)
|
||||||
|
const page = await context.newPage()
|
||||||
|
await login(page)
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/flows/probe`, { waitUntil: "networkidle" })
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
await page.screenshot({ path: `${dir}/endpoints-canvas.png` })
|
||||||
|
|
||||||
|
if (theme === "light") {
|
||||||
|
// Watch the canvas while a second tab moves the real slider.
|
||||||
|
const watch = page.evaluate(async () => {
|
||||||
|
const seen = new Set()
|
||||||
|
const scan = () => {
|
||||||
|
for (const el of document.querySelectorAll(".edge-live")) {
|
||||||
|
const holder = el.closest("[data-id]")
|
||||||
|
if (holder) seen.add(holder.getAttribute("data-id"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const observer = new MutationObserver(scan)
|
||||||
|
observer.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
subtree: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
})
|
||||||
|
await new Promise((r) => setTimeout(r, 6000))
|
||||||
|
observer.disconnect()
|
||||||
|
return [...seen]
|
||||||
|
})
|
||||||
|
|
||||||
|
const other = await context.newPage()
|
||||||
|
await other.goto(`${APP_URL}/dashboards/probe`, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
})
|
||||||
|
await other.waitForTimeout(1200)
|
||||||
|
const slider = other.locator('input[type="range"]').first()
|
||||||
|
await slider.click()
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await slider.press("ArrowRight")
|
||||||
|
await other.waitForTimeout(700)
|
||||||
|
}
|
||||||
|
const pulsed = await watch
|
||||||
|
console.log(
|
||||||
|
" edges that pulsed while the slider moved:",
|
||||||
|
JSON.stringify(pulsed),
|
||||||
|
)
|
||||||
|
await other.close()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await page.screenshot({ path: `${dir}/endpoints-after-slider.png` })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ${theme}: done`)
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
@@ -307,6 +307,53 @@ export const DashboardsPublicSchema = {
|
|||||||
title: 'DashboardsPublic'
|
title: 'DashboardsPublic'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const EndpointSchema = {
|
||||||
|
properties: {
|
||||||
|
kind: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Kind'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Id'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Label'
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Detail',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
provides: {
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Provides',
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
requires: {
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Requires',
|
||||||
|
default: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['kind', 'id', 'label'],
|
||||||
|
title: 'Endpoint',
|
||||||
|
description: `Something wired into this flow that is not a node in it.
|
||||||
|
|
||||||
|
A dashboard control setting one of its messages, a tile showing one, or a
|
||||||
|
node in another flow on the far side of a dotted name. The canvas draws
|
||||||
|
these so a value never appears to come from nowhere — or worse, appears to
|
||||||
|
come from whichever node happens to be drawn as a producer.`
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const FlowDef_InputSchema = {
|
export const FlowDef_InputSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
@@ -416,6 +463,14 @@ export const FlowDetailSchema = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
title: 'Paused',
|
title: 'Paused',
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/Endpoint'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Endpoints',
|
||||||
|
default: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1950,6 +2005,26 @@ export const app__api__routes__messages__PublishRequestSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
value: {
|
value: {
|
||||||
title: 'Value'
|
title: 'Value'
|
||||||
|
},
|
||||||
|
source_kind: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Source Kind',
|
||||||
|
default: 'api'
|
||||||
|
},
|
||||||
|
source_id: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Source Id',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
source_label: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Source Label',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
source_detail: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Source Detail',
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export type app__api__routes__messages__MessageValue = {
|
|||||||
|
|
||||||
export type app__api__routes__messages__PublishRequest = {
|
export type app__api__routes__messages__PublishRequest = {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
source_kind?: string;
|
||||||
|
source_id?: string;
|
||||||
|
source_label?: string;
|
||||||
|
source_detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +119,23 @@ export type DashboardSummary = {
|
|||||||
*/
|
*/
|
||||||
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
|
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something wired into this flow that is not a node in it.
|
||||||
|
*
|
||||||
|
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||||
|
* node in another flow on the far side of a dotted name. The canvas draws
|
||||||
|
* these so a value never appears to come from nowhere — or worse, appears to
|
||||||
|
* come from whichever node happens to be drawn as a producer.
|
||||||
|
*/
|
||||||
|
export type Endpoint = {
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
provides?: Array<(string)>;
|
||||||
|
requires?: Array<(string)>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One atomic flow.
|
* One atomic flow.
|
||||||
*/
|
*/
|
||||||
@@ -151,6 +172,7 @@ export type FlowDetail = {
|
|||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
endpoints?: Array<Endpoint>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export function DashboardEditor({
|
|||||||
|
|
||||||
<SectionGrid
|
<SectionGrid
|
||||||
section={section}
|
section={section}
|
||||||
|
dashboard={draft.name}
|
||||||
renderWidget={(widget) => (
|
renderWidget={(widget) => (
|
||||||
<WidgetFrame
|
<WidgetFrame
|
||||||
title={widget.title}
|
title={widget.title}
|
||||||
@@ -220,7 +221,7 @@ export function DashboardEditor({
|
|||||||
className="min-h-0 flex-1 text-left"
|
className="min-h-0 flex-1 text-left"
|
||||||
onClick={() => setSelected(widget.id)}
|
onClick={() => setSelected(widget.id)}
|
||||||
>
|
>
|
||||||
<WidgetBody widget={widget} />
|
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||||
</button>
|
</button>
|
||||||
</WidgetFrame>
|
</WidgetFrame>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ export function widgetStyle(widget: WidgetDef): React.CSSProperties {
|
|||||||
|
|
||||||
export function SectionGrid({
|
export function SectionGrid({
|
||||||
section,
|
section,
|
||||||
|
dashboard,
|
||||||
renderWidget,
|
renderWidget,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
section: SectionDef_Output
|
section: SectionDef_Output
|
||||||
|
/** Which dashboard this is, so an input widget can name itself. */
|
||||||
|
dashboard: string
|
||||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -72,7 +75,7 @@ export function SectionGrid({
|
|||||||
renderWidget(widget)
|
renderWidget(widget)
|
||||||
) : (
|
) : (
|
||||||
<WidgetFrame title={widget.title}>
|
<WidgetFrame title={widget.title}>
|
||||||
<WidgetBody widget={widget} />
|
<WidgetBody widget={widget} dashboard={dashboard} />
|
||||||
</WidgetFrame>
|
</WidgetFrame>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +125,7 @@ export function DashboardView({
|
|||||||
<SectionGrid
|
<SectionGrid
|
||||||
key={section.id}
|
key={section.id}
|
||||||
section={section}
|
section={section}
|
||||||
|
dashboard={dashboard.name}
|
||||||
renderWidget={renderWidget}
|
renderWidget={renderWidget}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -47,10 +47,39 @@ export function useSaveDashboard(name: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What an input widget does: put a value into the graph. */
|
/** What an input widget does: put a value into the graph.
|
||||||
|
*
|
||||||
|
* The widget names itself so the flow canvas can show the value arriving from
|
||||||
|
* here, rather than crediting whichever node is drawn as a producer.
|
||||||
|
*/
|
||||||
export function usePublishMessage() {
|
export function usePublishMessage() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
|
mutationFn: ({
|
||||||
MessagesService.publishMessage({ name, requestBody: { value } }),
|
name,
|
||||||
|
value,
|
||||||
|
dashboard,
|
||||||
|
widget,
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
value: unknown
|
||||||
|
dashboard?: string
|
||||||
|
widget?: string
|
||||||
|
label?: string
|
||||||
|
kind?: string
|
||||||
|
}) =>
|
||||||
|
MessagesService.publishMessage({
|
||||||
|
name,
|
||||||
|
requestBody: {
|
||||||
|
value,
|
||||||
|
source_kind: "dashboard",
|
||||||
|
// Matches the endpoint id the canvas builds for this widget.
|
||||||
|
source_id:
|
||||||
|
dashboard && widget ? `dashboard:${dashboard}:${widget}` : "",
|
||||||
|
source_label: label ?? widget ?? "Dashboard",
|
||||||
|
source_detail: kind ?? "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function Unbound() {
|
|||||||
// Display
|
// Display
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function StatWidget({ widget }: { widget: WidgetDef }) {
|
function StatWidget({ widget }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const message = text(cfg.message)
|
const message = text(cfg.message)
|
||||||
const live = useLiveValue(message || undefined)
|
const live = useLiveValue(message || undefined)
|
||||||
@@ -154,7 +154,7 @@ function StatWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
* The number is always written out as well: a reading that only exists as an
|
* The number is always written out as well: a reading that only exists as an
|
||||||
* angle is unreadable to anyone who cannot judge one.
|
* angle is unreadable to anyone who cannot judge one.
|
||||||
*/
|
*/
|
||||||
function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
function GaugeWidget({ widget }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const message = text(cfg.message)
|
const message = text(cfg.message)
|
||||||
const live = useLiveValue(message || undefined)
|
const live = useLiveValue(message || undefined)
|
||||||
@@ -229,7 +229,7 @@ function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
*
|
*
|
||||||
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
||||||
*/
|
*/
|
||||||
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
function MarkdownWidget({ widget }: WidgetProps) {
|
||||||
const content = text(config(widget).content)
|
const content = text(config(widget).content)
|
||||||
const lines = content.split("\n")
|
const lines = content.split("\n")
|
||||||
return (
|
return (
|
||||||
@@ -263,7 +263,7 @@ function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Publishing, with the value shown as sent until the engine confirms it. */
|
/** Publishing, with the value shown as sent until the engine confirms it. */
|
||||||
function usePublish(widget: WidgetDef) {
|
function usePublish(widget: WidgetDef, dashboard: string) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const target = text(cfg.target)
|
const target = text(cfg.target)
|
||||||
const publish = usePublishMessage()
|
const publish = usePublishMessage()
|
||||||
@@ -273,15 +273,22 @@ function usePublish(widget: WidgetDef) {
|
|||||||
live,
|
live,
|
||||||
send: (value: unknown) => {
|
send: (value: unknown) => {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
publish.mutate({ name: target, value })
|
publish.mutate({
|
||||||
|
name: target,
|
||||||
|
value,
|
||||||
|
dashboard,
|
||||||
|
widget: widget.id,
|
||||||
|
label: widget.title || widget.id,
|
||||||
|
kind: widget.type,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
pending: publish.isPending,
|
pending: publish.isPending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, send, pending } = usePublish(widget)
|
const { target, send, pending } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -295,8 +302,8 @@ function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const { target, live, send } = usePublish(widget)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -310,9 +317,9 @@ function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SliderWidget({ widget }: { widget: WidgetDef }) {
|
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
const min = num(cfg.min, 0)
|
const min = num(cfg.min, 0)
|
||||||
const max = num(cfg.max, 100)
|
const max = num(cfg.max, 100)
|
||||||
const step = num(cfg.step, 1)
|
const step = num(cfg.step, 1)
|
||||||
@@ -357,9 +364,9 @@ function SliderWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputWidget({ widget }: { widget: WidgetDef }) {
|
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
const [draft, setDraft] = useState<string | null>(null)
|
const [draft, setDraft] = useState<string | null>(null)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
@@ -384,9 +391,9 @@ function InputWidget({ widget }: { widget: WidgetDef }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownWidget({ widget }: { widget: WidgetDef }) {
|
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
@@ -420,8 +427,10 @@ function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WidgetProps = { widget: WidgetDef; dashboard: string }
|
||||||
|
|
||||||
const RENDERERS: Partial<
|
const RENDERERS: Partial<
|
||||||
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
|
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
|
||||||
> = {
|
> = {
|
||||||
stat: StatWidget,
|
stat: StatWidget,
|
||||||
gauge: GaugeWidget,
|
gauge: GaugeWidget,
|
||||||
@@ -433,7 +442,7 @@ const RENDERERS: Partial<
|
|||||||
dropdown: DropdownWidget,
|
dropdown: DropdownWidget,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
||||||
const Renderer = RENDERERS[widget.type]
|
const Renderer = RENDERERS[widget.type]
|
||||||
if (!Renderer) {
|
if (!Renderer) {
|
||||||
return (
|
return (
|
||||||
@@ -442,5 +451,5 @@ export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <Renderer widget={widget} />
|
return <Renderer widget={widget} dashboard={dashboard} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ export function EdgeInspector({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Several nodes can publish one message, and so can a dashboard or
|
||||||
|
another flow — so the value alone does not say what caused it. */}
|
||||||
|
{live?.source && live.source.kind !== "node" ? (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Last set from {live.source.label}
|
||||||
|
{live.source.detail ? ` (${live.source.detail})` : ""}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{live === undefined ? (
|
{live === undefined ? (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Nothing has come through yet. Run the flow to see a value here.
|
Nothing has come through yet. Run the flow to see a value here.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||||
|
import { LayoutDashboard, Workflow } from "lucide-react"
|
||||||
|
import { memo } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { EndpointNodeData } from "./endpoints"
|
||||||
|
|
||||||
|
const KIND_ICONS = {
|
||||||
|
dashboard: LayoutDashboard,
|
||||||
|
flow: Workflow,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thing wired into this flow that is not a node in it.
|
||||||
|
*
|
||||||
|
* Drawn as a label rather than a card on purpose: a dashboard with twenty
|
||||||
|
* tiles would otherwise bury the logic the canvas exists to show, which is
|
||||||
|
* the same reason dashboards are their own documents. It is here to account
|
||||||
|
* for a value, not to compete with the nodes for attention.
|
||||||
|
*/
|
||||||
|
function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||||
|
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
||||||
|
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
||||||
|
const messages = [...provides, ...requires]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-48 items-center gap-2 px-1 py-0.5 text-muted-foreground",
|
||||||
|
selected && "text-foreground",
|
||||||
|
)}
|
||||||
|
title={messages.join("\n")}
|
||||||
|
>
|
||||||
|
{/* Both sides always exist so an edge can attach; only one is used. */}
|
||||||
|
{requires.map((message) => (
|
||||||
|
<Handle
|
||||||
|
key={`in-${message}`}
|
||||||
|
type="target"
|
||||||
|
id={message}
|
||||||
|
position={Position.Left}
|
||||||
|
className="!border-border !bg-card"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{provides.map((message) => (
|
||||||
|
<Handle
|
||||||
|
key={`out-${message}`}
|
||||||
|
type="source"
|
||||||
|
id={message}
|
||||||
|
position={Position.Right}
|
||||||
|
className="!border-border !bg-card"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
<span className="grid min-w-0">
|
||||||
|
<span className="truncate text-sm leading-tight">{label}</span>
|
||||||
|
<span className="truncate text-xs tracking-wide uppercase opacity-70">
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointNode = memo(EndpointNodeComponent)
|
||||||
|
export default EndpointNode
|
||||||
@@ -42,6 +42,8 @@ import useCustomToast from "@/hooks/useCustomToast"
|
|||||||
import { CommandPalette } from "./CommandPalette"
|
import { CommandPalette } from "./CommandPalette"
|
||||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||||
|
import { EndpointNode } from "./EndpointNode"
|
||||||
|
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
||||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||||
import { FlowPanel } from "./FlowPanel"
|
import { FlowPanel } from "./FlowPanel"
|
||||||
@@ -61,7 +63,10 @@ import {
|
|||||||
} from "./queries"
|
} from "./queries"
|
||||||
import { useFlowSocket } from "./useFlowSocket"
|
import { useFlowSocket } from "./useFlowSocket"
|
||||||
|
|
||||||
const nodeTypes = { flow: FlowNode }
|
const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode }
|
||||||
|
|
||||||
|
/** Is this canvas node actually part of the flow document? */
|
||||||
|
const isDocumentNode = (node: { id: string }) => !isEndpointNode(node)
|
||||||
const edgeTypes = { live: LiveEdge }
|
const edgeTypes = { live: LiveEdge }
|
||||||
|
|
||||||
type Rebind = {
|
type Rebind = {
|
||||||
@@ -380,11 +385,50 @@ function FlowEditorInner({
|
|||||||
}
|
}
|
||||||
}, [key])
|
}, [key])
|
||||||
|
|
||||||
|
/** Where clicking an endpoint takes you: the thing it stands for. */
|
||||||
|
const openEndpoint = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const [kind, rest] = id.split(":", 2)
|
||||||
|
if (kind === "dashboard") {
|
||||||
|
navigate({
|
||||||
|
to: "/dashboards/$name",
|
||||||
|
params: { name: (rest ?? "").split(":")[0] },
|
||||||
|
})
|
||||||
|
} else if (kind === "flow") {
|
||||||
|
navigate({
|
||||||
|
to: "/flows/$flowName",
|
||||||
|
params: { flowName: (rest ?? "").split(".")[0] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dashboards and other flows wired into this one. They are drawn but never
|
||||||
|
// stored: they join at render, after everything that reads or writes
|
||||||
|
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
||||||
|
const external = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveEndpoints(
|
||||||
|
detail.endpoints ?? [],
|
||||||
|
definitions,
|
||||||
|
flowName,
|
||||||
|
new Map(canvasNodes.map((node) => [node.id, node.position])),
|
||||||
|
),
|
||||||
|
[detail.endpoints, key, flowName],
|
||||||
|
)
|
||||||
|
|
||||||
// Edges follow from the name bindings, so they are derived, never stored.
|
// Edges follow from the name bindings, so they are derived, never stored.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||||
const edges = useMemo(
|
const edges = useMemo(
|
||||||
() => deriveEdges(definitions, flowName),
|
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
||||||
[key, flowName],
|
[key, flowName, external],
|
||||||
|
)
|
||||||
|
|
||||||
|
const shownNodes = useMemo(
|
||||||
|
() => [...renderedNodes, ...external.nodes],
|
||||||
|
[renderedNodes, external],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Editing ports adds and removes handles. React Flow measures those once, so
|
// Editing ports adds and removes handles. React Flow measures those once, so
|
||||||
@@ -680,14 +724,25 @@ function FlowEditorInner({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={renderedNodes}
|
nodes={shownNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onNodeDragStop={(_event, _node, dragged) =>
|
onNodeDragStop={(_event, _node, dragged) =>
|
||||||
commit(definitions, mergeDragged(canvasNodes, dragged))
|
commit(
|
||||||
|
definitions,
|
||||||
|
mergeDragged(canvasNodes, dragged.filter(isDocumentNode)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNodesDelete={(deleted) =>
|
||||||
|
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||||
}
|
}
|
||||||
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
|
|
||||||
onNodeClick={(_event, node) => {
|
onNodeClick={(_event, node) => {
|
||||||
|
// An endpoint is somewhere else's: opening its panel here would
|
||||||
|
// offer to edit a node this flow does not contain.
|
||||||
|
if (!isDocumentNode(node)) {
|
||||||
|
openEndpoint(node.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
setFlowPanelOpen(false)
|
setFlowPanelOpen(false)
|
||||||
setSelectedId(node.id)
|
setSelectedId(node.id)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function LiveEdgeComponent({
|
|||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const { message } = (data ?? {}) as FlowEdgeData
|
const { message, producerId } = (data ?? {}) as FlowEdgeData
|
||||||
const live = useLiveValue(message)
|
const live = useLiveValue(message)
|
||||||
const zoom = useStore((state) => state.transform[2])
|
const zoom = useStore((state) => state.transform[2])
|
||||||
const [pulsing, setPulsing] = useState(false)
|
const [pulsing, setPulsing] = useState(false)
|
||||||
@@ -49,14 +49,20 @@ function LiveEdgeComponent({
|
|||||||
targetPosition,
|
targetPosition,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restart the stroke animation whenever a newer message lands.
|
// Restart the stroke animation whenever a newer message lands — but only
|
||||||
|
// for the producer that actually published it. A message can have several
|
||||||
|
// producers, and can also be set from a dashboard or another flow, so
|
||||||
|
// pulsing on the value alone claims things happened that did not.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!live?.ts || live.ts === lastTs.current) return
|
if (!live?.ts || live.ts === lastTs.current) return
|
||||||
lastTs.current = live.ts
|
lastTs.current = live.ts
|
||||||
|
const from = live.source
|
||||||
|
// No source at all is an older engine; pulse rather than go silent.
|
||||||
|
if (from && from.id !== producerId) return
|
||||||
setPulsing(true)
|
setPulsing(true)
|
||||||
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
|
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [live?.ts])
|
}, [live?.ts, live?.source, producerId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function portOf(spec: MessageSpec): string {
|
|||||||
export type FlowEdgeData = {
|
export type FlowEdgeData = {
|
||||||
message: string
|
message: string
|
||||||
flow: string
|
flow: string
|
||||||
|
/** Whose publication this edge represents, so only it pulses. */
|
||||||
|
producerId: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +67,11 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
|
|||||||
target: node.id,
|
target: node.id,
|
||||||
targetHandle: targetPort,
|
targetHandle: targetPort,
|
||||||
type: "live",
|
type: "live",
|
||||||
data: { message, flow } satisfies FlowEdgeData,
|
data: {
|
||||||
|
message,
|
||||||
|
flow,
|
||||||
|
producerId: `${flow}.${producer.node}`,
|
||||||
|
} satisfies FlowEdgeData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
|
||||||
|
|
||||||
|
import type { Endpoint, NodeDef_Input } from "@/client"
|
||||||
|
import type { FlowEdgeData } from "./deriveEdges"
|
||||||
|
import { portOf, qualify } from "./deriveEdges"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas elements for things wired into a flow that are not nodes in it.
|
||||||
|
*
|
||||||
|
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||||
|
* node in another flow across a dotted name. Without these the canvas shows a
|
||||||
|
* value changing with nothing to account for it — and pulses whichever node
|
||||||
|
* happens to be drawn as a producer, which did nothing.
|
||||||
|
*
|
||||||
|
* They are never part of the document: this returns display-only nodes that
|
||||||
|
* are appended after everything which reads or writes `canvasNodes`, so an
|
||||||
|
* autosave, an undo or a delete cannot reach them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Marks a canvas node as one of these, for anything that has to skip them. */
|
||||||
|
export const ENDPOINT_TYPE = "endpoint"
|
||||||
|
|
||||||
|
export type EndpointNodeData = {
|
||||||
|
label: string
|
||||||
|
kind: string
|
||||||
|
detail: string
|
||||||
|
/** Messages it publishes into this flow, and ones it reads out of it. */
|
||||||
|
provides: string[]
|
||||||
|
requires: string[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lanes either side of the graph, so a label never lands on a node. */
|
||||||
|
const GAP_X = 120
|
||||||
|
const STACK_Y = 64
|
||||||
|
/** Roughly a node's width; only used to find the right-hand lane. */
|
||||||
|
const NODE_W = 220
|
||||||
|
|
||||||
|
export function isEndpointNode(node: { id: string }): boolean {
|
||||||
|
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place the endpoints and wire them to the nodes they touch.
|
||||||
|
*
|
||||||
|
* Positions are computed rather than stored: an endpoint is not part of the
|
||||||
|
* flow, so there is nowhere to keep a position that would not be a lie about
|
||||||
|
* what the document contains. A producer sits left of what it feeds, a
|
||||||
|
* consumer right of what feeds it.
|
||||||
|
*/
|
||||||
|
export function deriveEndpoints(
|
||||||
|
endpoints: Endpoint[],
|
||||||
|
definitions: NodeDef_Input[],
|
||||||
|
flow: string,
|
||||||
|
positions: Map<string, { x: number; y: number }>,
|
||||||
|
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
|
||||||
|
if (endpoints.length === 0) return { nodes: [], edges: [] }
|
||||||
|
|
||||||
|
// Which node consumes or produces each message, so a label can sit beside it.
|
||||||
|
const consumers = new Map<string, { node: string; port: string }[]>()
|
||||||
|
const producers = new Map<string, { node: string; port: string }[]>()
|
||||||
|
for (const node of definitions) {
|
||||||
|
for (const spec of node.requires ?? []) {
|
||||||
|
const message = qualify(flow, spec.name ?? "")
|
||||||
|
if (!message) continue
|
||||||
|
consumers.set(message, [
|
||||||
|
...(consumers.get(message) ?? []),
|
||||||
|
{ node: node.id, port: portOf(spec) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
for (const spec of node.provides ?? []) {
|
||||||
|
const message = qualify(flow, spec.name ?? "")
|
||||||
|
if (!message) continue
|
||||||
|
producers.set(message, [
|
||||||
|
...(producers.get(message) ?? []),
|
||||||
|
{ node: node.id, port: portOf(spec) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lane either side of the graph. Anchoring each label to the node it
|
||||||
|
// feeds put them on top of the nodes, so they live outside the whole thing
|
||||||
|
// instead: producers to the left of everything, consumers to the right.
|
||||||
|
const placed = [...positions.values()]
|
||||||
|
const bounds = {
|
||||||
|
left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0,
|
||||||
|
right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0,
|
||||||
|
top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: FlowCanvasNode[] = []
|
||||||
|
const edges: Edge[] = []
|
||||||
|
// How many labels already sit on each side, so they stack instead of overlap.
|
||||||
|
const stacked = { left: 0, right: 0 }
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const produces = endpoint.provides ?? []
|
||||||
|
const reads = endpoint.requires ?? []
|
||||||
|
// A producer belongs upstream of what it feeds; everything else downstream.
|
||||||
|
const side = produces.length > 0 ? "left" : "right"
|
||||||
|
|
||||||
|
const index = stacked[side]
|
||||||
|
stacked[side] += 1
|
||||||
|
nodes.push({
|
||||||
|
id: endpoint.id,
|
||||||
|
type: ENDPOINT_TYPE,
|
||||||
|
position: {
|
||||||
|
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
|
||||||
|
y: bounds.top + index * STACK_Y,
|
||||||
|
},
|
||||||
|
// Not part of the document, and not the author's to rearrange.
|
||||||
|
draggable: false,
|
||||||
|
selectable: true,
|
||||||
|
deletable: false,
|
||||||
|
data: {
|
||||||
|
label: endpoint.label,
|
||||||
|
kind: endpoint.kind,
|
||||||
|
detail: endpoint.detail ?? "",
|
||||||
|
provides: produces,
|
||||||
|
requires: reads,
|
||||||
|
} satisfies EndpointNodeData,
|
||||||
|
})
|
||||||
|
|
||||||
|
// An endpoint that publishes feeds every node consuming that message.
|
||||||
|
for (const message of produces) {
|
||||||
|
for (const consumer of consumers.get(message) ?? []) {
|
||||||
|
edges.push({
|
||||||
|
id: `${endpoint.id}->${consumer.node}:${consumer.port}`,
|
||||||
|
source: endpoint.id,
|
||||||
|
sourceHandle: message,
|
||||||
|
target: consumer.node,
|
||||||
|
targetHandle: consumer.port,
|
||||||
|
type: "live",
|
||||||
|
data: {
|
||||||
|
message,
|
||||||
|
flow,
|
||||||
|
producerId: endpoint.id,
|
||||||
|
} satisfies FlowEdgeData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One that reads is fed by every node producing it.
|
||||||
|
for (const message of reads) {
|
||||||
|
for (const producer of producers.get(message) ?? []) {
|
||||||
|
edges.push({
|
||||||
|
id: `${producer.node}:${producer.port}->${endpoint.id}`,
|
||||||
|
source: producer.node,
|
||||||
|
sourceHandle: producer.port,
|
||||||
|
target: endpoint.id,
|
||||||
|
targetHandle: message,
|
||||||
|
type: "live",
|
||||||
|
data: {
|
||||||
|
message,
|
||||||
|
flow,
|
||||||
|
producerId: `${flow}.${producer.node}`,
|
||||||
|
} satisfies FlowEdgeData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges }
|
||||||
|
}
|
||||||
@@ -8,7 +8,18 @@ import { useSyncExternalStore } from "react"
|
|||||||
* nothing else.
|
* nothing else.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type LiveValue = { value: unknown; ts: number | null }
|
/** Who caused a value. The canvas needs it to pulse the right edge. */
|
||||||
|
export type ValueSource = {
|
||||||
|
kind: "node" | "dashboard" | "flow" | "agent" | "api"
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
export type LiveValue = {
|
||||||
|
value: unknown
|
||||||
|
ts: number | null
|
||||||
|
source?: ValueSource
|
||||||
|
}
|
||||||
export type LiveStatus = {
|
export type LiveStatus = {
|
||||||
status: "active" | "error" | "running" | "success"
|
status: "active" | "error" | "running" | "success"
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
import { OpenAPI } from "@/client"
|
import { OpenAPI } from "@/client"
|
||||||
import { type LogLine, liveStore } from "./liveStore"
|
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||||
import { flowKeys } from "./queries"
|
import { flowKeys } from "./queries"
|
||||||
|
|
||||||
const RECONNECT_MIN = 1000
|
const RECONNECT_MIN = 1000
|
||||||
@@ -19,7 +19,13 @@ type FlowEvent =
|
|||||||
paused?: string[]
|
paused?: string[]
|
||||||
logs?: LogLine[]
|
logs?: LogLine[]
|
||||||
}
|
}
|
||||||
| { type: "message_value"; name: string; value: unknown; ts: number }
|
| {
|
||||||
|
type: "message_value"
|
||||||
|
name: string
|
||||||
|
value: unknown
|
||||||
|
ts: number
|
||||||
|
source?: ValueSource
|
||||||
|
}
|
||||||
| { type: "node_executed"; node: string; outputs: number }
|
| { type: "node_executed"; node: string; outputs: number }
|
||||||
| { type: "node_error"; node: string; error: string }
|
| { type: "node_error"; node: string; error: string }
|
||||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||||
@@ -88,6 +94,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
|||||||
liveStore.setValue(message.name, {
|
liveStore.setValue(message.name, {
|
||||||
value: message.value,
|
value: message.value,
|
||||||
ts: message.ts,
|
ts: message.ts,
|
||||||
|
source: message.source,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "node_executed":
|
case "node_executed":
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboards and other flows appear on the canvas but are not part of the flow.
|
||||||
|
*
|
||||||
|
* The risk this covers: they are React Flow nodes like any other, so anything
|
||||||
|
* that writes the document — autosave, undo, delete, drag — could pick one up
|
||||||
|
* and store it. A dashboard widget written into flow.json would become a node
|
||||||
|
* the engine then tries to build.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const flowName = `test_endpoint_${Date.now().toString(36)}`
|
||||||
|
const dashboardName = `${flowName}_panel`
|
||||||
|
|
||||||
|
test.use({ storageState: "playwright/.auth/user.json" })
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" })
|
||||||
|
|
||||||
|
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||||
|
|
||||||
|
async function api(
|
||||||
|
page: Page,
|
||||||
|
path: string,
|
||||||
|
init: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
||||||
|
return page.request.fetch(`${apiUrl}/api/v1${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
const page = await browser.newPage({
|
||||||
|
storageState: "playwright/.auth/user.json",
|
||||||
|
})
|
||||||
|
await page.goto("/")
|
||||||
|
|
||||||
|
// A flow reading a message, and a dashboard control that sets it.
|
||||||
|
await api(page, `/flows/${flowName}`, {
|
||||||
|
method: "PUT",
|
||||||
|
data: {
|
||||||
|
name: flowName,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "sink",
|
||||||
|
type: "python",
|
||||||
|
requires: [{ name: "level", port: "level", dtype: "float" }],
|
||||||
|
provides: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ spec: { name: "level", port: "level", dtype: "float" }, initial: 0 },
|
||||||
|
],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const saved = await (await api(page, `/flows/${flowName}`)).json()
|
||||||
|
await api(page, `/flows/${flowName}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
data: { version: saved.definition.version },
|
||||||
|
})
|
||||||
|
|
||||||
|
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
|
||||||
|
const dashboard = await (
|
||||||
|
await api(page, `/dashboards/${dashboardName}`)
|
||||||
|
).json()
|
||||||
|
dashboard.pages[0].sections[0].widgets = [
|
||||||
|
{
|
||||||
|
id: "lever",
|
||||||
|
type: "slider",
|
||||||
|
title: "Lever",
|
||||||
|
layout: {},
|
||||||
|
config: { target: `${flowName}.level` },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await api(page, `/dashboards/${dashboardName}`, {
|
||||||
|
method: "PUT",
|
||||||
|
data: dashboard,
|
||||||
|
})
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
const page = await browser.newPage({
|
||||||
|
storageState: "playwright/.auth/user.json",
|
||||||
|
})
|
||||||
|
await page.goto("/")
|
||||||
|
await api(page, `/dashboards/${dashboardName}`, { method: "DELETE" })
|
||||||
|
await api(page, `/flows/${flowName}`, { method: "DELETE" })
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a dashboard control is drawn on the flow it feeds", async ({ page }) => {
|
||||||
|
await page.goto(`/flows/${flowName}`)
|
||||||
|
await page.waitForSelector(".react-flow__node")
|
||||||
|
|
||||||
|
await expect(page.getByText("Lever")).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moving a node does not save the dashboard into the flow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/flows/${flowName}`)
|
||||||
|
const node = page.locator(".react-flow__node-flow").first()
|
||||||
|
await node.waitFor()
|
||||||
|
|
||||||
|
const box = await node.boundingBox()
|
||||||
|
if (!box) throw new Error("the node has no position to drag from")
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40)
|
||||||
|
await page.mouse.up()
|
||||||
|
// The autosave is debounced; give it room to land.
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
||||||
|
|
||||||
|
expect(detail.definition.nodes.map((n: { id: string }) => n.id)).toEqual([
|
||||||
|
"sink",
|
||||||
|
])
|
||||||
|
// Still reported as an endpoint, just never as a node.
|
||||||
|
expect(detail.endpoints.map((e: { label: string }) => e.label)).toEqual([
|
||||||
|
"Lever",
|
||||||
|
])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user