Add flow settings, pulse emitting nodes, and simplify node state

- One dot per node now carries the whole story: primary while running, sage
  after a good run, red when anything is wrong, with the explanation on hover.
  The corner badge is gone, along with the second way of saying the same thing.
- A node that publishes something flashes a ring, so a running flow is legible
  without reading the edge values. Nodes that consume but publish nothing stay
  quiet, which is why the event carries an output count.
- Flow settings open in the same panel its nodes use, from a pencil in the
  dock: the title, the name, and deleting the flow. NodePanel and FlowPanel
  share the panel chrome rather than each drawing their own.
- Renaming is a server operation, because a flow's name is the namespace of its
  messages: the directory moves and every other flow reading `old.message` is
  repointed, instead of being left pointing at a flow that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 19:46:23 +02:00
co-authored by Claude Fable 5
parent c254d487ba
commit fd666743d2
18 changed files with 756 additions and 197 deletions
+33 -1
View File
@@ -21,6 +21,7 @@ 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
from app.flow.schemas import ( from app.flow.schemas import (
NAME_PATTERN,
FlowDef, FlowDef,
FlowsPublic, FlowsPublic,
FlowStatePublic, FlowStatePublic,
@@ -30,7 +31,7 @@ from app.flow.schemas import (
NodeStatusPublic, NodeStatusPublic,
NodeTypeInfo, NodeTypeInfo,
) )
from app.flow.store import FlowNotFound from app.flow.store import FlowExists, FlowNotFound
from app.models import Message from app.models import Message
router = APIRouter( router = APIRouter(
@@ -53,6 +54,10 @@ class ValidationResult(BaseModel):
issues: list[ValidationIssue] = [] issues: list[ValidationIssue] = []
class RenameRequest(BaseModel):
new_name: str
class RunRequest(BaseModel): class RunRequest(BaseModel):
inputs: dict[str, Any] = {} inputs: dict[str, Any] = {}
@@ -154,6 +159,33 @@ async def delete_flow(name: str, controller: FlowControllerDep) -> Any:
return Message(message=f"Deleted flow '{name}'") return Message(message=f"Deleted flow '{name}'")
@router.post("/{name}/rename", response_model=FlowDetail)
async def rename_flow(
name: str,
body: RenameRequest,
controller: FlowControllerDep,
) -> Any:
"""Rename a flow, along with every reference to its messages."""
if not NAME_PATTERN.match(body.new_name):
raise HTTPException(
status_code=400,
detail=(
"Use lowercase letters, digits and underscores, starting with a letter"
),
)
try:
renamed = await run_in_threadpool(
controller.store.rename_flow, name, body.new_name
)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
except FlowExists as exc:
raise HTTPException(status_code=409, detail=str(exc))
await controller.reload()
return _detail(controller, renamed)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Node source # Node source
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+15
View File
@@ -338,6 +338,9 @@ class Pipeline:
"type": "node_executed", "type": "node_executed",
"flow": node.flow, "flow": node.flow,
"node": node.id, "node": node.id,
# A node that returns nothing ran but published nothing,
# which is a different thing to show than one that emitted.
"outputs": len(result or {}),
"duration_ms": round((time.perf_counter() - started) * 1000, 2), "duration_ms": round((time.perf_counter() - started) * 1000, 2),
"ts": time.time(), "ts": time.time(),
} }
@@ -443,6 +446,18 @@ class Pipeline:
"ts": ts, "ts": ts,
} }
) )
# An injecting node — an MQTT subscriber, a webhook — publishes
# without going through the executor, but it did emit.
self._publish(
{
"type": "node_executed",
"flow": node.flow,
"node": node.id,
"outputs": len(outputs),
"duration_ms": 0,
"ts": ts,
}
)
downstream = set(self._get_downstream(node)) downstream = set(self._get_downstream(node))
if not downstream: if not downstream:
+53
View File
@@ -34,6 +34,15 @@ class FlowNotFound(KeyError):
return f"No flow named '{self.name}'" return f"No flow named '{self.name}'"
class FlowExists(ValueError):
def __init__(self, name: str) -> None:
super().__init__(name)
self.name = name
def __str__(self) -> str:
return f"There is already a flow named '{self.name}'"
class FlowStore: class FlowStore:
"""Reads and writes flows, committing every change.""" """Reads and writes flows, committing every change."""
@@ -129,6 +138,50 @@ class FlowStore:
shutil.rmtree(directory) shutil.rmtree(directory)
self._commit(f"Delete flow '{name}'") self._commit(f"Delete flow '{name}'")
def rename_flow(self, name: str, new_name: str) -> FlowDef:
"""Rename a flow, carrying its nodes and any references to it.
A flow's name is the namespace of its messages, so other flows reading
``old.temperature`` are rewritten to read ``new.temperature`` — leaving
them pointing at a flow that no longer exists would break them silently.
"""
if not self.exists(name):
raise FlowNotFound(name)
if self.exists(new_name):
raise FlowExists(new_name)
flow = self.read_flow(name)
self._flow_dir(name).rename(self._flow_dir(new_name))
renamed = flow.model_copy(update={"name": new_name})
self._flow_file(new_name).write_text(renamed.model_dump_json(indent=2) + "\n")
for other in self.read_all():
if other.name == new_name:
continue
if self._retarget(other, f"{name}.", f"{new_name}."):
self._flow_file(other.name).write_text(
other.model_dump_json(indent=2) + "\n"
)
self._commit(f"Rename flow '{name}' to '{new_name}'")
return renamed
@staticmethod
def _retarget(flow: FlowDef, old_prefix: str, new_prefix: str) -> bool:
"""Point this flow's cross-flow message names at a renamed flow."""
changed = False
for node in flow.nodes:
for specs in (node.requires, node.provides):
for position, spec in enumerate(specs):
if spec.name.startswith(old_prefix):
tail = spec.name[len(old_prefix) :]
specs[position] = spec.model_copy(
update={"name": new_prefix + tail}
)
changed = True
return changed
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Node source # Node source
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+62
View File
@@ -144,3 +144,65 @@ def test_node_types_are_listed(
by_type = {entry["type"]: entry for entry in types} by_type = {entry["type"]: entry for entry in types}
assert by_type["python"]["has_source"] is True assert by_type["python"]["has_source"] is True
assert "properties" in by_type["mqtt"]["params_schema"] assert "properties" in by_type["mqtt"]["params_schema"]
def test_rename_flow(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
response = client.post(
f"{PREFIX}/demo/rename",
headers=superuser_token_headers,
json={"new_name": "demo_renamed"},
)
assert response.status_code == 200
assert response.json()["definition"]["name"] == "demo_renamed"
assert (
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404
)
assert (
client.get(
f"{PREFIX}/demo_renamed", headers=superuser_token_headers
).status_code
== 200
)
# Put it back so the tests that follow find the flow they expect.
client.post(
f"{PREFIX}/demo_renamed/rename",
headers=superuser_token_headers,
json={"new_name": "demo"},
)
def test_rename_onto_a_taken_name_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
client.put(
f"{PREFIX}/occupied", headers=superuser_token_headers, json=a_flow("occupied")
)
response = client.post(
f"{PREFIX}/demo/rename",
headers=superuser_token_headers,
json={"new_name": "occupied"},
)
assert response.status_code == 409
client.delete(f"{PREFIX}/occupied", headers=superuser_token_headers)
def test_rename_rejects_an_invalid_name(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
response = client.post(
f"{PREFIX}/demo/rename",
headers=superuser_token_headers,
json={"new_name": "Not A Flow Name"},
)
assert response.status_code == 400
+45 -1
View File
@@ -5,7 +5,7 @@ import pytest
from app.flow.messages import MessageSpec from app.flow.messages import MessageSpec
from app.flow.schemas import FlowDef, NodeDef from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowNotFound, FlowStore from app.flow.store import FlowExists, FlowNotFound, FlowStore
@pytest.fixture @pytest.fixture
@@ -73,3 +73,47 @@ def test_deleting_removes_flow_and_its_nodes(store: FlowStore):
assert store.list_flows() == [] assert store.list_flows() == []
assert not (store.root / "heating").exists() assert not (store.root / "heating").exists()
def test_renaming_a_flow_carries_its_nodes(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source(
"heating", "sensor", "def process(params):\n return {}\n"
)
renamed = store.rename_flow("heating", "warmth")
assert renamed.name == "warmth"
assert store.list_flows() == ["warmth"]
assert "def process" in store.read_node_source("warmth", "sensor")
def test_renaming_a_flow_repoints_the_flows_reading_it(store: FlowStore):
store.write_flow(a_flow())
store.write_flow(
FlowDef(
name="display",
nodes=[
NodeDef(
id="gauge",
# Reads across the flow boundary, so the name must follow.
requires=[MessageSpec(name="heating.temp")],
)
],
)
)
store.rename_flow("heating", "warmth")
display = store.read_flow("display")
assert display.nodes[0].requires[0].name == "warmth.temp"
def test_renaming_onto_an_existing_name_is_refused(store: FlowStore):
store.write_flow(a_flow())
store.write_flow(FlowDef(name="warmth"))
with pytest.raises(FlowExists):
store.rename_flow("heating", "warmth")
assert store.list_flows() == ["heating", "warmth"]
+12
View File
@@ -586,6 +586,18 @@ export const PrivateUserCreateSchema = {
title: 'PrivateUserCreate' title: 'PrivateUserCreate'
} as const; } as const;
export const RenameRequestSchema = {
properties: {
new_name: {
type: 'string',
title: 'New Name'
}
},
type: 'object',
required: ['new_name'],
title: 'RenameRequest'
} as const;
export const RunRequestSchema = { export const RunRequestSchema = {
properties: { properties: {
inputs: { inputs: {
+25 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class FlowsService { export class FlowsService {
/** /**
@@ -98,6 +98,30 @@ export class FlowsService {
}); });
} }
/**
* Rename Flow
* Rename a flow, along with every reference to its messages.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns FlowDetail Successful Response
* @throws ApiError
*/
public static renameFlow(data: FlowsRenameFlowData): CancelablePromise<FlowsRenameFlowResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/rename',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/** /**
* Read Node Source * Read Node Source
* Read a node's Python source. * Read a node's Python source.
+11
View File
@@ -193,6 +193,10 @@ export type PrivateUserCreate = {
is_verified?: boolean; is_verified?: boolean;
}; };
export type RenameRequest = {
new_name: string;
};
export type RunRequest = { export type RunRequest = {
inputs?: { inputs?: {
[key: string]: unknown; [key: string]: unknown;
@@ -313,6 +317,13 @@ export type FlowsDeleteFlowData = {
export type FlowsDeleteFlowResponse = (Message); export type FlowsDeleteFlowResponse = (Message);
export type FlowsRenameFlowData = {
name: string;
requestBody: RenameRequest;
};
export type FlowsRenameFlowResponse = (FlowDetail);
export type FlowsReadNodeSourceData = { export type FlowsReadNodeSourceData = {
name: string; name: string;
nodeId: string; nodeId: string;
+19
View File
@@ -3,6 +3,7 @@ import {
AlertCircle, AlertCircle,
Loader2, Loader2,
Maximize2, Maximize2,
Pencil,
Play, Play,
Plus, Plus,
ZoomIn, ZoomIn,
@@ -33,12 +34,14 @@ export function FlowDock({
issues, issues,
running, running,
onAddNode, onAddNode,
onEditFlow,
onRun, onRun,
onFocusNode, onFocusNode,
}: { }: {
issues: ValidationIssue[] issues: ValidationIssue[]
running: boolean running: boolean
onAddNode: () => void onAddNode: () => void
onEditFlow: () => void
onRun: () => void onRun: () => void
onFocusNode: (nodeId: string) => void onFocusNode: (nodeId: string) => void
}) { }) {
@@ -68,6 +71,22 @@ export function FlowDock({
<TooltipContent>Add a node (K)</TooltipContent> <TooltipContent>Add a node (K)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" /> <Separator orientation="vertical" className="mx-0.5 !h-5" />
<Button <Button
+55 -1
View File
@@ -41,12 +41,14 @@ import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { FlowDock } from "./FlowDock" import { FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs" import { FlowTabs } from "./FlowTabs"
import { LiveEdge } from "./LiveEdge" import { LiveEdge } from "./LiveEdge"
import { NodePanel } from "./NodePanel" import { NodePanel } from "./NodePanel"
import "./flow.css" import "./flow.css"
import { liveStore } from "./liveStore" import { liveStore } from "./liveStore"
import { import {
flowKeys,
flowQueryOptions, flowQueryOptions,
flowsQueryOptions, flowsQueryOptions,
nodeTypesQueryOptions, nodeTypesQueryOptions,
@@ -96,6 +98,7 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
} }
function FlowEditorInner({ flowName }: { flowName: string }) { function FlowEditorInner({ flowName }: { flowName: string }) {
const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast() const { showErrorToast } = useCustomToast()
const { screenToFlowPosition, fitView } = useReactFlow() const { screenToFlowPosition, fitView } = useReactFlow()
@@ -117,6 +120,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null) const [inspected, setInspected] = useState<InspectedEdge | null>(null)
const [rebind, setRebind] = useState<Rebind | null>(null) const [rebind, setRebind] = useState<Rebind | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
const issues = detail.issues ?? [] const issues = detail.issues ?? []
@@ -228,6 +232,32 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
showErrorToast("The flow could not run. Check the node errors."), showErrorToast("The flow could not run. Check the node errors."),
}) })
const renameMutation = useMutation({
mutationFn: (newName: string) =>
FlowsService.renameFlow({ name: flowName, requestBody: { new_name: newName } }),
onSuccess: (detail) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
navigate({
to: "/flows/$flowName",
params: { flowName: detail.definition.name },
replace: true,
})
},
onError: () =>
showErrorToast("That name is taken, or is not a valid flow name."),
})
const deleteMutation = useMutation({
mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
navigate({ to: "/flows", replace: true })
},
onError: () => showErrorToast("The flow could not be deleted."),
})
const sourceMutation = useMutation({ const sourceMutation = useMutation({
mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) => mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) =>
FlowsService.saveNodeSource({ FlowsService.saveNodeSource({
@@ -406,7 +436,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
commit(definitions, mergeDragged(canvasNodes, dragged)) commit(definitions, mergeDragged(canvasNodes, dragged))
} }
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))} onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
onNodeClick={(_event, node) => setSelectedId(node.id)} onNodeClick={(_event, node) => {
setFlowPanelOpen(false)
setSelectedId(node.id)
}}
onPaneClick={() => { onPaneClick={() => {
setSelectedId(null) setSelectedId(null)
setInspected(null) setInspected(null)
@@ -453,6 +486,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
issues={issues} issues={issues}
running={runMutation.isPending} running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)} onAddNode={() => setPaletteOpen(true)}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
onRun={() => { onRun={() => {
flush() flush()
runMutation.mutate() runMutation.mutate()
@@ -475,6 +512,23 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
</div> </div>
) : null} ) : null}
<FlowPanel
open={flowPanelOpen && !selected}
definition={{ ...detail.definition, nodes: definitions }}
nodeCount={definitions.length}
renaming={renameMutation.isPending}
onChange={(next) => {
flush()
save({ ...next, nodes: definitions })
}}
onRename={(newName) => {
flush()
renameMutation.mutate(newName)
}}
onDelete={() => deleteMutation.mutate()}
onClose={() => setFlowPanelOpen(false)}
/>
<NodePanel <NodePanel
node={selected} node={selected}
flow={flowName} flow={flowName}
+15 -34
View File
@@ -1,13 +1,5 @@
import { Handle, type NodeProps, Position } from "@xyflow/react" import { Handle, type NodeProps, Position } from "@xyflow/react"
import { import { Braces, Clock, Code2, Database, Globe, Radio } from "lucide-react"
AlertCircle,
Braces,
Clock,
Code2,
Database,
Globe,
Radio,
} from "lucide-react"
import { memo } from "react" import { memo } from "react"
import type { MessageSpec, NodeDef_Input } from "@/client" import type { MessageSpec, NodeDef_Input } from "@/client"
@@ -18,7 +10,7 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges" import { portOf } from "./deriveEdges"
import { useNodeStatus } from "./liveStore" import { useNodeEmits, useNodeStatus } from "./liveStore"
const NODE_ICONS = { const NODE_ICONS = {
python: Code2, python: Code2,
@@ -29,11 +21,12 @@ const NODE_ICONS = {
mlp: Braces, mlp: Braces,
} as const } as const
// Only the states worth a quiet marker. Anything wrong goes to the badge // One dot says everything about a node's state. Idle nodes carry no dot at all,
// instead, so a problem is never reported twice on the same node. // so the canvas stays quiet until something happens.
const STATUS_STYLES = { const STATUS_STYLES = {
running: { dot: "bg-primary animate-pulse", label: "Running" }, running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" }, success: { dot: "bg-status-success", label: "Last run succeeded" },
error: { dot: "bg-destructive", label: "Something went wrong" },
} as const } as const
export type FlowNodeData = { export type FlowNodeData = {
@@ -85,16 +78,16 @@ function PortHandles({
function FlowNodeComponent({ data, selected }: NodeProps) { function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, issueText } = data as FlowNodeData const { definition, flow, typeLabel, issueText } = data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`) const live = useNodeStatus(`${flow}.${definition.id}`)
const emits = useNodeEmits(`${flow}.${definition.id}`)
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2 const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
// Whatever is wrong — it failed to load, it failed to run, or the graph // Whatever is wrong — it failed to load, it failed to run, or the graph
// around it does not add up — is one badge with one explanation. // around it does not add up — is the same red dot with the same explanation.
const problem = [live?.status === "error" ? live.error : null, issueText] const problem = [live?.status === "error" ? live.error : null, issueText]
.filter(Boolean) .filter(Boolean)
.join("\n") .join("\n")
const style = problem const status = problem ? "error" : live?.status
? undefined const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
: STATUS_STYLES[live?.status as keyof typeof STATUS_STYLES]
return ( return (
<div <div
@@ -103,6 +96,9 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
selected && "border-primary shadow-e2", selected && "border-primary shadow-e2",
)} )}
> >
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
<PortHandles <PortHandles
specs={definition.requires ?? []} specs={definition.requires ?? []}
type="target" type="target"
@@ -130,28 +126,13 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
aria-label={style.label} aria-label={style.label}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{live?.error ?? style.label}</TooltipContent> <TooltipContent className="max-w-xs whitespace-pre-line">
{problem || style.label}
</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
{problem ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label="This node has a problem"
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground"
>
<AlertCircle className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-pre-line">
{problem}
</TooltipContent>
</Tooltip>
) : null}
<PortHandles <PortHandles
specs={definition.provides ?? []} specs={definition.provides ?? []}
type="source" type="source"
+154
View File
@@ -0,0 +1,154 @@
import { useState } from "react"
import type { FlowDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
/**
* The flow's own settings, in the same panel its nodes use.
*
* The name is also the namespace of every message in the flow, which is why
* renaming goes through the server rather than being another autosaved field.
*/
export function FlowPanel({
open,
definition,
nodeCount,
renaming,
onChange,
onRename,
onDelete,
onClose,
}: {
open: boolean
definition: FlowDef_Input
nodeCount: number
renaming: boolean
onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void
onDelete: () => void
onClose: () => void
}) {
const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false)
const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name
return (
<>
<SidePanel
open={open}
label="Flow settings"
testId="flow-panel"
bodyKey={definition.name}
onClose={onClose}
header={
<Input
value={definition.title ?? ""}
placeholder={definition.name}
aria-label="Flow title"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) =>
onChange({ ...definition, title: event.target.value })
}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
data-testid="delete-flow"
>
Delete flow
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Name</span>
<div className="flex items-center gap-1.5">
<Input
value={name}
aria-label="Flow name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && valid && changed) {
onRename(name)
}
}}
/>
<Button
size="sm"
className="h-8"
disabled={!valid || !changed || renaming}
onClick={() => onRename(name)}
>
{renaming ? "Renaming…" : "Rename"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{valid || !name
? "Messages in this flow are named after it, so other flows reading them follow the rename."
: "Lowercase letters, digits and underscores, starting with a letter."}
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
{nodeCount === 0
? "No nodes yet."
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
</p>
</div>
</div>
</SidePanel>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {definition.title || definition.name}?
</DialogTitle>
<DialogDescription>
This removes the flow and the code of its{" "}
{nodeCount === 1 ? "node" : `${nodeCount} nodes`}. Its history
stays in the flow store's git repository.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
}}
data-testid="confirm-delete-flow"
>
Delete flow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+77 -156
View File
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { X } from "lucide-react" import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { lazy, Suspense, useEffect, useRef, useState } from "react" import { lazy, Suspense, useEffect, useRef, useState } from "react"
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client" import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
@@ -22,33 +21,15 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
import { nodeSourceQueryOptions } from "./queries" import { nodeSourceQueryOptions } from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor")) const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"] const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
const SECTION = const SECTION = PANEL_SECTION
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
const panelSlide = {
hidden: { opacity: 0, x: 16 },
visible: {
opacity: 1,
x: 0,
transition: { duration: duration.base, ease: easeEmphasized },
},
exit: {
opacity: 0,
x: 16,
transition: { duration: duration.fast, ease: easeStandard },
},
}
/** /**
* A message name, typed freely or picked from the names already in play. * A message name, typed freely or picked from the names already in play.
@@ -297,8 +278,6 @@ function PanelBody({
suggestions, suggestions,
onChange, onChange,
onSaveSource, onSaveSource,
onClose,
onDelete,
}: { }: {
node: NodeDef_Input node: NodeDef_Input
flow: string flow: string
@@ -306,8 +285,6 @@ function PanelBody({
suggestions: PortSuggestions suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void onSaveSource: (code: string) => void
onClose: () => void
onDelete: () => void
}) { }) {
const hasSource = nodeType?.has_source ?? node.type === "python" const hasSource = nodeType?.has_source ?? node.type === "python"
const { data: source } = useQuery({ const { data: source } = useQuery({
@@ -343,78 +320,47 @@ function PanelBody({
return ( return (
<> <>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3"> <div className="grid gap-5 p-4">
<Input <PortList
value={node.title || node.id} title="Consumes"
aria-label="Node name" specs={node.requires ?? []}
className="h-8 flex-1 text-sm font-medium" flow={flow}
onChange={(event) => onChange({ ...node, title: event.target.value })} emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => onChange({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
/>
<ParamsForm
schema={nodeType?.params_schema}
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
/> />
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={onClose}
aria-label="Close"
>
<X />
</Button>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> {hasSource ? (
<div className="grid gap-5 p-4"> <div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<PortList <span className={SECTION}>Code</span>
title="Consumes" <div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
specs={node.requires ?? []} <Suspense
flow={flow} fallback={
emptyHint="Nothing yet. Add a message this node reads." <div className="h-full w-full animate-pulse bg-muted" />
suggestions={suggestions.consumes} }
onChange={(requires) => onChange({ ...node, requires })} >
/> <NodeEditor
<PortList value={code ?? source?.code ?? ""}
title="Provides" onChange={editCode}
specs={node.provides ?? []} />
flow={flow} </Suspense>
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
/>
<ParamsForm
schema={nodeType?.params_schema}
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
/>
</div>
{hasSource ? (
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<span className={SECTION}>Code</span>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
<div className="h-full w-full animate-pulse bg-muted" />
}
>
<NodeEditor
value={code ?? source?.code ?? ""}
onChange={editCode}
/>
</Suspense>
</div>
</div> </div>
) : null} </div>
</div> ) : null}
<div className="shrink-0 border-t border-border px-4 py-3">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
>
Delete node
</Button>
</div>
</> </>
) )
} }
@@ -445,73 +391,48 @@ export function NodePanel({
onClose: () => void onClose: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const isMobile = useIsMobile()
const nodeType = nodeTypes.find((entry) => entry.type === node?.type) const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
useEffect(() => {
if (!node) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [node, onClose])
if (isMobile) {
return (
<Sheet open={Boolean(node)} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
// The panel header carries its own close button, and opening should
// not drop the caret into the node's name.
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetTitle className="sr-only">Node settings</SheetTitle>
{node ? (
<PanelBody
key={node.id}
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
onDelete={onDelete}
/>
) : null}
</SheetContent>
</Sheet>
)
}
return ( return (
<AnimatePresence> <SidePanel
{node ? ( open={Boolean(node)}
<motion.aside label="Node settings"
key={node.id} testId="node-panel"
variants={panelSlide} bodyKey={node?.id ?? "none"}
initial="hidden" onClose={onClose}
animate="visible" header={
exit="exit" node ? (
role="complementary" <Input
aria-label="Node settings" value={node.title || node.id}
data-testid="node-panel" aria-label="Node name"
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md" className="h-8 flex-1 text-sm font-medium"
> onChange={(event) =>
<PanelBody onChange({ ...node, title: event.target.value })
node={node} }
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
onDelete={onDelete}
/> />
</motion.aside> ) : null
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
>
Delete node
</Button>
}
>
{node ? (
<PanelBody
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
/>
) : null} ) : null}
</AnimatePresence> </SidePanel>
) )
} }
+135
View File
@@ -0,0 +1,135 @@
import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
const panelSlide = {
hidden: { opacity: 0, x: 16 },
visible: {
opacity: 1,
x: 0,
transition: { duration: duration.base, ease: easeEmphasized },
},
exit: {
opacity: 0,
x: 16,
transition: { duration: duration.fast, ease: easeStandard },
},
}
export const PANEL_SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/**
* The editor's settings panel: floating over the canvas so the graph stays
* visible and running behind it, a full-screen sheet where there is no room
* for that.
*
* Node settings and flow settings share it, so the two read as one surface.
*/
export function SidePanel({
open,
label,
testId,
bodyKey,
header,
footer,
children,
onClose,
}: {
open: boolean
/** Names the panel for screen readers. */
label: string
testId: string
/** Remounts the contents when the thing being edited changes. */
bodyKey: string
header: ReactNode
footer?: ReactNode
children: ReactNode
onClose: () => void
}) {
const isMobile = useIsMobile()
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onClose])
const contents = (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
{header}
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={onClose}
aria-label="Close"
>
<X />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{children}
</div>
{footer ? (
<div className="shrink-0 border-t border-border px-4 py-3">
{footer}
</div>
) : null}
</>
)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="right"
// The panel header carries its own close button, and opening should
// not drop the caret into the first field.
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetTitle className="sr-only">{label}</SheetTitle>
{open ? (
<div key={bodyKey} className="contents">
{contents}
</div>
) : null}
</SheetContent>
</Sheet>
)
}
return (
<AnimatePresence>
{open ? (
<motion.aside
key={bodyKey}
variants={panelSlide}
initial="hidden"
animate="visible"
exit="exit"
role="complementary"
aria-label={label}
data-testid={testId}
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
>
{contents}
</motion.aside>
) : null}
</AnimatePresence>
)
}
+24
View File
@@ -49,6 +49,30 @@
} }
} }
/* A node that just published something says so, once, and settles. */
@media (prefers-reduced-motion: no-preference) {
.node-pulse {
position: absolute;
inset: -3px;
border-radius: inherit;
border: 2px solid var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
pointer-events: none;
animation: node-pulse var(--duration-slow) var(--ease-emphasized) forwards;
}
@keyframes node-pulse {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(1.09);
}
}
}
/* Handles are neutral: the one brand-secondary affordance here is Run. */ /* Handles are neutral: the one brand-secondary affordance here is Run. */
.react-flow__handle { .react-flow__handle {
width: 12px; width: 12px;
+17
View File
@@ -18,6 +18,9 @@ type Listener = () => void
const values = new Map<string, LiveValue>() const values = new Map<string, LiveValue>()
const statuses = new Map<string, LiveStatus>() const statuses = new Map<string, LiveStatus>()
// How many times a node has emitted. The number itself means nothing; a change
// is what restarts the pulse.
const emits = new Map<string, number>()
const listeners = new Map<string, Set<Listener>>() const listeners = new Map<string, Set<Listener>>()
let connected = false let connected = false
@@ -72,6 +75,10 @@ export const liveStore = {
getStatus(nodeId: string) { getStatus(nodeId: string) {
return statuses.get(nodeId) return statuses.get(nodeId)
}, },
recordEmit(nodeId: string) {
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
notify(`emit:${nodeId}`)
},
setConnected(next: boolean) { setConnected(next: boolean) {
if (connected === next) return if (connected === next) return
connected = next connected = next
@@ -85,6 +92,8 @@ export const liveStore = {
values.clear() values.clear()
for (const key of statuses.keys()) notify(`status:${key}`) for (const key of statuses.keys()) notify(`status:${key}`)
statuses.clear() statuses.clear()
for (const key of emits.keys()) notify(`emit:${key}`)
emits.clear()
}, },
} }
@@ -102,6 +111,14 @@ export function useNodeStatus(nodeId: string): LiveStatus | undefined {
) )
} }
/** Increments each time the node publishes something. */
export function useNodeEmits(nodeId: string): number {
return useSyncExternalStore(
(listener) => subscribeKey(`emit:${nodeId}`, listener),
() => emits.get(nodeId) ?? 0,
)
}
export function useLiveConnection(): boolean { export function useLiveConnection(): boolean {
return useSyncExternalStore( return useSyncExternalStore(
(listener) => { (listener) => {
@@ -13,7 +13,7 @@ type FlowEvent =
nodes: { id: string; status: string; error?: string | null }[] nodes: { id: string; status: string; error?: string | null }[]
} }
| { type: "message_value"; name: string; value: unknown; ts: number } | { type: "message_value"; name: string; value: unknown; ts: number }
| { type: "node_executed"; node: string } | { 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 }
| { | {
@@ -71,6 +71,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
break break
case "node_executed": case "node_executed":
liveStore.setStatus(message.node, { status: "success" }) liveStore.setStatus(message.node, { status: "success" })
if (message.outputs > 0) liveStore.recordEmit(message.node)
break break
case "node_error": case "node_error":
liveStore.setStatus(message.node, { liveStore.setStatus(message.node, {
+2 -2
View File
@@ -35,8 +35,8 @@
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1); --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-base: 200ms; --duration-base: 250ms;
--duration-slow: 300ms; --duration-slow: 500ms;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);