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:
co-authored by
Claude Fable 5
parent
c254d487ba
commit
fd666743d2
@@ -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
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user