Add the flow editor: canvas, node panel and live values
The browser half of M3. Flows open on a full-bleed canvas with their chrome floating over it: flow tabs top, dock bottom, node settings in a panel on the right that leaves the graph visible and running behind it. - Connections are derived, not stored. A node declares the messages it reads and publishes; every matching pair draws an edge, so two producers of one message converge on their consumer. Dragging output to input is shorthand for pointing that input at the producer's message, and asks before it replaces an existing one. - Values land on the edges as they flow, over a websocket that feeds a store outside React, so a value arriving re-renders its own chip and nothing else. Clicking an edge shows the last payload and when it arrived. - Node source is edited in Monaco, loaded only when a panel opens and themed from the design tokens. - Edits autosave; identical documents are skipped server-side, so a quiet canvas writes nothing. - Validation from the API shows on the node it belongs to and is summarised in the dock, where each entry pans to its node. - Works on a phone: touch-connect, 44px dock targets, and the node panel becomes a full-screen sheet. Two new tokens (--status-success, --font-mono) are mirrored in the website repo and recorded in DESIGN-GUIDELINES.md. 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
06a4506767
commit
8c82549cf6
@@ -14,16 +14,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -32,13 +35,17 @@
|
||||
"@tanstack/react-router": "^1.157.3",
|
||||
"@tanstack/react-router-devtools": "^1.156.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"axios": "1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"form-data": "4.0.5",
|
||||
"lucide-react": "^0.562.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"motion": "^13.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
|
||||
@@ -23,8 +23,10 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'blob' : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
/* Base URL to use in actions like `await page.goto('/')`.
|
||||
Point PLAYWRIGHT_BASE_URL at the integrated stack (http://app.localhost)
|
||||
to test what `make dev` is serving; the API only allows CORS from there. */
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5173',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
@@ -82,10 +84,13 @@ export default defineConfig({
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'bun run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
/* Run your local dev server before starting the tests, unless we were
|
||||
pointed at an already-running one. */
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: 'bun run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,8 +61,48 @@ for (const theme of ["light", "dark"]) {
|
||||
await page.waitForLoadState("networkidle")
|
||||
await page.screenshot({ path: `${dir}/app-dashboard.png` })
|
||||
|
||||
await captureFlows(page, dir)
|
||||
|
||||
await context.close()
|
||||
console.log(` wrote ${dir}/{website-hero,app-login,app-dashboard}.png`)
|
||||
console.log(
|
||||
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel}.png`,
|
||||
)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
|
||||
/**
|
||||
* The flow editor, empty-handed if the instance has no flows yet: seeds one
|
||||
* with a node so the canvas and the node panel are both worth looking at.
|
||||
*/
|
||||
async function captureFlows(page, dir) {
|
||||
await page.goto(`${APP_URL}/flows`, { waitUntil: "networkidle" })
|
||||
|
||||
const seed = page.getByTestId("create-first-flow")
|
||||
if (await seed.count()) {
|
||||
await seed.click()
|
||||
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
|
||||
}
|
||||
|
||||
if (!(await page.locator(".react-flow__node").count())) {
|
||||
await page.getByTestId("add-node").click()
|
||||
await page
|
||||
.getByRole("option", { name: /function/i })
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForSelector(".react-flow__node")
|
||||
}
|
||||
|
||||
// Adding a node opens its panel; the canvas shot wants it out of the way.
|
||||
await page.keyboard.press("Escape")
|
||||
// The dock and tabs slide in; let them land before the shutter.
|
||||
await page.waitForTimeout(1200)
|
||||
await page.screenshot({ path: `${dir}/app-flows.png` })
|
||||
|
||||
await page.locator(".react-flow__node").first().click()
|
||||
await page.waitForSelector("[data-testid=node-panel], .monaco-editor", {
|
||||
timeout: 15000,
|
||||
})
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: `${dir}/app-flow-panel.png` })
|
||||
}
|
||||
|
||||
@@ -57,6 +57,227 @@ export const Body_login_login_access_tokenSchema = {
|
||||
title: 'Body_login-login_access_token'
|
||||
} as const;
|
||||
|
||||
export const DTypeSchema = {
|
||||
type: 'string',
|
||||
enum: ['float', 'int', 'str', 'bool', 'json'],
|
||||
title: 'DType',
|
||||
description: `Serializable payload types.
|
||||
|
||||
Binary payloads (tensors, images) will arrive later as explicitly declared
|
||||
codec fields; until then everything on the wire is JSON.`
|
||||
} as const;
|
||||
|
||||
export const FlowDef_InputSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
nodes: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/NodeDef-Input'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes'
|
||||
},
|
||||
inputs: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/FlowInput-Input'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Inputs'
|
||||
},
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
title: 'FlowDef',
|
||||
description: 'One atomic flow.'
|
||||
} as const;
|
||||
|
||||
export const FlowDef_OutputSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
nodes: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/NodeDef-Output'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes'
|
||||
},
|
||||
inputs: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/FlowInput-Output'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Inputs'
|
||||
},
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
title: 'FlowDef',
|
||||
description: 'One atomic flow.'
|
||||
} as const;
|
||||
|
||||
export const FlowDetailSchema = {
|
||||
properties: {
|
||||
definition: {
|
||||
'$ref': '#/components/schemas/FlowDef-Output'
|
||||
},
|
||||
nodes: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/NodeStatusPublic'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes',
|
||||
default: []
|
||||
},
|
||||
issues: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/ValidationIssue'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Issues',
|
||||
default: []
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['definition'],
|
||||
title: 'FlowDetail',
|
||||
description: 'A flow plus how it is currently doing.'
|
||||
} as const;
|
||||
|
||||
export const FlowInput_InputSchema = {
|
||||
properties: {
|
||||
spec: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
initial: {
|
||||
anyOf: [
|
||||
{},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Initial'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['spec'],
|
||||
title: 'FlowInput',
|
||||
description: 'A message the flow starts with rather than computes.'
|
||||
} as const;
|
||||
|
||||
export const FlowInput_OutputSchema = {
|
||||
properties: {
|
||||
spec: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
initial: {
|
||||
anyOf: [
|
||||
{},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Initial'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['spec'],
|
||||
title: 'FlowInput',
|
||||
description: 'A message the flow starts with rather than computes.'
|
||||
} as const;
|
||||
|
||||
export const FlowStatePublicSchema = {
|
||||
properties: {
|
||||
values: {
|
||||
additionalProperties: {
|
||||
'$ref': '#/components/schemas/MessageValue'
|
||||
},
|
||||
type: 'object',
|
||||
title: 'Values'
|
||||
},
|
||||
nodes: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/NodeStatusPublic'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'FlowStatePublic'
|
||||
} as const;
|
||||
|
||||
export const FlowSummarySchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
node_count: {
|
||||
type: 'integer',
|
||||
title: 'Node Count',
|
||||
default: 0
|
||||
},
|
||||
error_count: {
|
||||
type: 'integer',
|
||||
title: 'Error Count',
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
title: 'FlowSummary'
|
||||
} as const;
|
||||
|
||||
export const FlowsPublicSchema = {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/FlowSummary'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Data'
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
title: 'Count'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['data', 'count'],
|
||||
title: 'FlowsPublic'
|
||||
} as const;
|
||||
|
||||
export const HTTPValidationErrorSchema = {
|
||||
properties: {
|
||||
detail: {
|
||||
@@ -208,6 +429,56 @@ export const MessageSchema = {
|
||||
title: 'Message'
|
||||
} as const;
|
||||
|
||||
export const MessageSpecSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name',
|
||||
default: ''
|
||||
},
|
||||
port: {
|
||||
type: 'string',
|
||||
title: 'Port',
|
||||
default: ''
|
||||
},
|
||||
dtype: {
|
||||
'$ref': '#/components/schemas/DType',
|
||||
default: 'float'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'MessageSpec',
|
||||
description: `A single port of a node, and the message it is bound to.
|
||||
|
||||
:param name: The message this port binds to. Bare names are qualified with
|
||||
the flow name at load time; empty means the port is unbound.
|
||||
:param port: The identifier the node function sees. Defaults to the last
|
||||
segment of \`\`name\`\`, so unqualified flows read naturally.
|
||||
:param dtype: Payload type, validated on every message that passes through.`
|
||||
} as const;
|
||||
|
||||
export const MessageValueSchema = {
|
||||
properties: {
|
||||
value: {
|
||||
title: 'Value'
|
||||
},
|
||||
ts: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Ts'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'MessageValue',
|
||||
description: 'The last payload seen on a message.'
|
||||
} as const;
|
||||
|
||||
export const NewPasswordSchema = {
|
||||
properties: {
|
||||
token: {
|
||||
@@ -226,6 +497,195 @@ export const NewPasswordSchema = {
|
||||
title: 'NewPassword'
|
||||
} as const;
|
||||
|
||||
export const NodeDef_InputSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
title: 'Type',
|
||||
default: 'python'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
position: {
|
||||
'$ref': '#/components/schemas/Position',
|
||||
default: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
},
|
||||
params: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Params'
|
||||
},
|
||||
requires: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Requires'
|
||||
},
|
||||
provides: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Provides'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'NodeDef',
|
||||
description: 'A node as stored: identity, placement, configuration and ports.'
|
||||
} as const;
|
||||
|
||||
export const NodeDef_OutputSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
title: 'Type',
|
||||
default: 'python'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
position: {
|
||||
'$ref': '#/components/schemas/Position',
|
||||
default: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
},
|
||||
params: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Params'
|
||||
},
|
||||
requires: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Requires'
|
||||
},
|
||||
provides: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/MessageSpec'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Provides'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'NodeDef',
|
||||
description: 'A node as stored: identity, placement, configuration and ports.'
|
||||
} as const;
|
||||
|
||||
export const NodeSourceSchema = {
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
title: 'Code'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
title: 'NodeSource',
|
||||
description: 'The Python source of a node.'
|
||||
} as const;
|
||||
|
||||
export const NodeStatusPublicSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
title: 'Status',
|
||||
default: 'active'
|
||||
},
|
||||
error: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Error'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'NodeStatusPublic',
|
||||
description: 'Whether a node loaded, and why not.'
|
||||
} as const;
|
||||
|
||||
export const NodeTypeInfoSchema = {
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
title: 'Type'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
title: 'Description'
|
||||
},
|
||||
params_schema: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Params Schema'
|
||||
},
|
||||
has_source: {
|
||||
type: 'boolean',
|
||||
title: 'Has Source',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['type', 'title', 'description'],
|
||||
title: 'NodeTypeInfo',
|
||||
description: 'A node type the editor can offer, with its parameter schema.'
|
||||
} as const;
|
||||
|
||||
export const PositionSchema = {
|
||||
properties: {
|
||||
x: {
|
||||
type: 'number',
|
||||
title: 'X',
|
||||
default: 0
|
||||
},
|
||||
y: {
|
||||
type: 'number',
|
||||
title: 'Y',
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'Position',
|
||||
description: 'Where a node sits on the canvas.'
|
||||
} as const;
|
||||
|
||||
export const PrivateUserCreateSchema = {
|
||||
properties: {
|
||||
email: {
|
||||
@@ -251,6 +711,50 @@ export const PrivateUserCreateSchema = {
|
||||
title: 'PrivateUserCreate'
|
||||
} as const;
|
||||
|
||||
export const RunRequestSchema = {
|
||||
properties: {
|
||||
inputs: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Inputs',
|
||||
default: {}
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'RunRequest'
|
||||
} as const;
|
||||
|
||||
export const SecretNamesSchema = {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Data'
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
title: 'Count'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['data', 'count'],
|
||||
title: 'SecretNames'
|
||||
} as const;
|
||||
|
||||
export const SecretValueSchema = {
|
||||
properties: {
|
||||
value: {
|
||||
type: 'string',
|
||||
title: 'Value'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['value'],
|
||||
title: 'SecretValue'
|
||||
} as const;
|
||||
|
||||
export const TokenSchema = {
|
||||
properties: {
|
||||
access_token: {
|
||||
@@ -268,6 +772,19 @@ export const TokenSchema = {
|
||||
title: 'Token'
|
||||
} as const;
|
||||
|
||||
export const TriggerRequestSchema = {
|
||||
properties: {
|
||||
values: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Values',
|
||||
default: {}
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'TriggerRequest'
|
||||
} as const;
|
||||
|
||||
export const UpdatePasswordSchema = {
|
||||
properties: {
|
||||
current_password: {
|
||||
@@ -549,4 +1066,83 @@ export const ValidationErrorSchema = {
|
||||
type: 'object',
|
||||
required: ['loc', 'msg', 'type'],
|
||||
title: 'ValidationError'
|
||||
} as const;
|
||||
|
||||
export const ValidationIssueSchema = {
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error'],
|
||||
title: 'Code'
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
title: 'Message'
|
||||
},
|
||||
flow: {
|
||||
type: 'string',
|
||||
title: 'Flow',
|
||||
default: ''
|
||||
},
|
||||
nodes: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes',
|
||||
default: []
|
||||
},
|
||||
node: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Node'
|
||||
},
|
||||
port: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Port'
|
||||
},
|
||||
message_name: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Message Name'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code', 'message'],
|
||||
title: 'ValidationIssue',
|
||||
description: 'A problem that keeps a flow from running correctly.'
|
||||
} as const;
|
||||
|
||||
export const ValidationResultSchema = {
|
||||
properties: {
|
||||
issues: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/ValidationIssue'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Issues',
|
||||
default: []
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'ValidationResult'
|
||||
} as const;
|
||||
@@ -3,7 +3,242 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, 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, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, 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 {
|
||||
/**
|
||||
* Read Flows
|
||||
* List every flow.
|
||||
* @returns FlowsPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readFlows(): CancelablePromise<FlowsReadFlowsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Node Types
|
||||
* The node types that can be placed on a canvas.
|
||||
* @returns NodeTypeInfo Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readNodeTypes(): CancelablePromise<FlowsReadNodeTypesResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/node-types'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Flow
|
||||
* Read one flow, with the state of its nodes.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readFlow(data: FlowsReadFlowData): CancelablePromise<FlowsReadFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/{name}',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Flow
|
||||
* Create or replace a flow. Saving the same content again changes nothing.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static saveFlow(data: FlowsSaveFlowData): CancelablePromise<FlowsSaveFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/api/v1/flows/{name}',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Flow
|
||||
* Delete a flow and everything in it.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteFlow(data: FlowsDeleteFlowData): CancelablePromise<FlowsDeleteFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/flows/{name}',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Node Source
|
||||
* Read a node's Python source.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @returns NodeSource Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readNodeSource(data: FlowsReadNodeSourceData): CancelablePromise<FlowsReadNodeSourceResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/source',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Node Source
|
||||
* Save a node's source and report whether it loads.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @param data.requestBody
|
||||
* @returns NodeStatusPublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static saveNodeSource(data: FlowsSaveNodeSourceData): CancelablePromise<FlowsSaveNodeSourceResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/source',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Flow
|
||||
* Report what would keep this flow from running.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns ValidationResult Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static validateFlow(data: FlowsValidateFlowData): CancelablePromise<FlowsValidateFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/validate',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Flow
|
||||
* Run every node of a flow once.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
* @returns FlowStatePublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static runFlow(data: FlowsRunFlowData): CancelablePromise<FlowsRunFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/run',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Node
|
||||
* Feed values into a single node.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @param data.requestBody
|
||||
* @returns FlowStatePublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static triggerNode(data: FlowsTriggerNodeData): CancelablePromise<FlowsTriggerNodeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/trigger',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Flow State
|
||||
* The last value seen on every message of this flow.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns FlowStatePublic Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readFlowState(data: FlowsReadFlowStateData): CancelablePromise<FlowsReadFlowStateResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/{name}/state',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ItemsService {
|
||||
/**
|
||||
@@ -235,6 +470,66 @@ export class PrivateService {
|
||||
}
|
||||
}
|
||||
|
||||
export class SecretsService {
|
||||
/**
|
||||
* Read Secrets
|
||||
* List the names of stored secrets.
|
||||
* @returns SecretNames Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readSecrets(): CancelablePromise<SecretsReadSecretsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/secrets/'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Secret
|
||||
* Store a secret under a name that nodes can reference.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static saveSecret(data: SecretsSaveSecretData): CancelablePromise<SecretsSaveSecretResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/api/v1/secrets/{name}',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Secret
|
||||
* Delete a secret.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteSecret(data: SecretsDeleteSecretData): CancelablePromise<SecretsDeleteSecretResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/secrets/{name}',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UsersService {
|
||||
/**
|
||||
* Read Users
|
||||
|
||||
@@ -9,6 +9,80 @@ export type Body_login_login_access_token = {
|
||||
client_secret?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable payload types.
|
||||
*
|
||||
* Binary payloads (tensors, images) will arrive later as explicitly declared
|
||||
* codec fields; until then everything on the wire is JSON.
|
||||
*/
|
||||
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
|
||||
|
||||
/**
|
||||
* One atomic flow.
|
||||
*/
|
||||
export type FlowDef_Input = {
|
||||
name: string;
|
||||
title?: string;
|
||||
nodes?: Array<NodeDef_Input>;
|
||||
inputs?: Array<FlowInput_Input>;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One atomic flow.
|
||||
*/
|
||||
export type FlowDef_Output = {
|
||||
name: string;
|
||||
title?: string;
|
||||
nodes?: Array<NodeDef_Output>;
|
||||
inputs?: Array<FlowInput_Output>;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A flow plus how it is currently doing.
|
||||
*/
|
||||
export type FlowDetail = {
|
||||
definition: FlowDef_Output;
|
||||
nodes?: Array<NodeStatusPublic>;
|
||||
issues?: Array<ValidationIssue>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message the flow starts with rather than computes.
|
||||
*/
|
||||
export type FlowInput_Input = {
|
||||
spec: MessageSpec;
|
||||
initial?: (unknown | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* A message the flow starts with rather than computes.
|
||||
*/
|
||||
export type FlowInput_Output = {
|
||||
spec: MessageSpec;
|
||||
initial?: (unknown | null);
|
||||
};
|
||||
|
||||
export type FlowsPublic = {
|
||||
data: Array<FlowSummary>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type FlowStatePublic = {
|
||||
values?: {
|
||||
[key: string]: MessageValue;
|
||||
};
|
||||
nodes?: Array<NodeStatusPublic>;
|
||||
};
|
||||
|
||||
export type FlowSummary = {
|
||||
name: string;
|
||||
title?: string;
|
||||
node_count?: number;
|
||||
error_count?: number;
|
||||
};
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>;
|
||||
};
|
||||
@@ -40,11 +114,101 @@ export type Message = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single port of a node, and the message it is bound to.
|
||||
*
|
||||
* :param name: The message this port binds to. Bare names are qualified with
|
||||
* the flow name at load time; empty means the port is unbound.
|
||||
* :param port: The identifier the node function sees. Defaults to the last
|
||||
* segment of ``name``, so unqualified flows read naturally.
|
||||
* :param dtype: Payload type, validated on every message that passes through.
|
||||
*/
|
||||
export type MessageSpec = {
|
||||
name?: string;
|
||||
port?: string;
|
||||
dtype?: DType;
|
||||
};
|
||||
|
||||
/**
|
||||
* The last payload seen on a message.
|
||||
*/
|
||||
export type MessageValue = {
|
||||
value?: unknown;
|
||||
ts?: (number | null);
|
||||
};
|
||||
|
||||
export type NewPassword = {
|
||||
token: string;
|
||||
new_password: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node as stored: identity, placement, configuration and ports.
|
||||
*/
|
||||
export type NodeDef_Input = {
|
||||
id: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
position?: Position;
|
||||
params?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node as stored: identity, placement, configuration and ports.
|
||||
*/
|
||||
export type NodeDef_Output = {
|
||||
id: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
position?: Position;
|
||||
params?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Python source of a node.
|
||||
*/
|
||||
export type NodeSource = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a node loaded, and why not.
|
||||
*/
|
||||
export type NodeStatusPublic = {
|
||||
id: string;
|
||||
status?: string;
|
||||
error?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* A node type the editor can offer, with its parameter schema.
|
||||
*/
|
||||
export type NodeTypeInfo = {
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
params_schema?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
has_source?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a node sits on the canvas.
|
||||
*/
|
||||
export type Position = {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
export type PrivateUserCreate = {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -52,11 +216,32 @@ export type PrivateUserCreate = {
|
||||
is_verified?: boolean;
|
||||
};
|
||||
|
||||
export type RunRequest = {
|
||||
inputs?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type SecretNames = {
|
||||
data: Array<(string)>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type SecretValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Token = {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
};
|
||||
|
||||
export type TriggerRequest = {
|
||||
values?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
@@ -109,6 +294,90 @@ export type ValidationError = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A problem that keeps a flow from running correctly.
|
||||
*/
|
||||
export type ValidationIssue = {
|
||||
code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error';
|
||||
message: string;
|
||||
flow?: string;
|
||||
nodes?: Array<(string)>;
|
||||
node?: (string | null);
|
||||
port?: (string | null);
|
||||
message_name?: (string | null);
|
||||
};
|
||||
|
||||
export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error';
|
||||
|
||||
export type ValidationResult = {
|
||||
issues?: Array<ValidationIssue>;
|
||||
};
|
||||
|
||||
export type FlowsReadFlowsResponse = (FlowsPublic);
|
||||
|
||||
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
|
||||
|
||||
export type FlowsReadFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsReadFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsSaveFlowData = {
|
||||
name: string;
|
||||
requestBody: FlowDef_Input;
|
||||
};
|
||||
|
||||
export type FlowsSaveFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsDeleteFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsDeleteFlowResponse = (Message);
|
||||
|
||||
export type FlowsReadNodeSourceData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export type FlowsReadNodeSourceResponse = (NodeSource);
|
||||
|
||||
export type FlowsSaveNodeSourceData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
requestBody: NodeSource;
|
||||
};
|
||||
|
||||
export type FlowsSaveNodeSourceResponse = (NodeStatusPublic);
|
||||
|
||||
export type FlowsValidateFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsValidateFlowResponse = (ValidationResult);
|
||||
|
||||
export type FlowsRunFlowData = {
|
||||
name: string;
|
||||
requestBody: RunRequest;
|
||||
};
|
||||
|
||||
export type FlowsRunFlowResponse = (FlowStatePublic);
|
||||
|
||||
export type FlowsTriggerNodeData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
requestBody: TriggerRequest;
|
||||
};
|
||||
|
||||
export type FlowsTriggerNodeResponse = (FlowStatePublic);
|
||||
|
||||
export type FlowsReadFlowStateData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsReadFlowStateResponse = (FlowStatePublic);
|
||||
|
||||
export type ItemsReadItemsData = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
@@ -173,6 +442,21 @@ export type PrivateCreateUserData = {
|
||||
|
||||
export type PrivateCreateUserResponse = (UserPublic);
|
||||
|
||||
export type SecretsReadSecretsResponse = (SecretNames);
|
||||
|
||||
export type SecretsSaveSecretData = {
|
||||
name: string;
|
||||
requestBody: SecretValue;
|
||||
};
|
||||
|
||||
export type SecretsSaveSecretResponse = (Message);
|
||||
|
||||
export type SecretsDeleteSecretData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SecretsDeleteSecretResponse = (Message);
|
||||
|
||||
export type UsersReadUsersData = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import type { FlowSummary, NodeTypeInfo } from "@/client"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
|
||||
/**
|
||||
* ⌘K: add a node, jump to another flow, or run the current one, without
|
||||
* reaching for the dock.
|
||||
*/
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
nodeTypes,
|
||||
flows,
|
||||
onAddNode,
|
||||
onRun,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
flows: FlowSummary[]
|
||||
onAddNode: (type: string) => void
|
||||
onRun: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
onOpenChange(!open)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Picking an item re-renders the canvas, which can interrupt the dialog's
|
||||
// exit animation and leave its overlay swallowing clicks. Unmounting the
|
||||
// dialog outright is deterministic; the palette does not need to fade out.
|
||||
if (!open) return null
|
||||
|
||||
const close = (action: () => void) => {
|
||||
onOpenChange(false)
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Commands"
|
||||
description="Add a node, switch flow, or run"
|
||||
>
|
||||
<CommandInput placeholder="Add a node, switch flow, run…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nothing matches that.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Add a node">
|
||||
{nodeTypes.map((nodeType) => (
|
||||
<CommandItem
|
||||
key={nodeType.type}
|
||||
value={`${nodeType.title} ${nodeType.description}`}
|
||||
onSelect={() => close(() => onAddNode(nodeType.type))}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span>{nodeType.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nodeType.description}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{flows.length > 0 ? (
|
||||
<CommandGroup heading="Flows">
|
||||
{flows.map((flow) => (
|
||||
<CommandItem
|
||||
key={flow.name}
|
||||
value={`flow ${flow.name} ${flow.title}`}
|
||||
onSelect={() =>
|
||||
close(() =>
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: flow.name },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{flow.title || flow.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem value="run flow" onSelect={() => close(onRun)}>
|
||||
Run this flow
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { displayName } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
|
||||
function relativeTime(ts: number | null | undefined): string {
|
||||
if (!ts) return "not seen yet"
|
||||
const seconds = Math.max(0, Math.round(Date.now() / 1000 - ts))
|
||||
if (seconds < 5) return "just now"
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
|
||||
return `${Math.round(seconds / 3600)}h ago`
|
||||
}
|
||||
|
||||
export type InspectedEdge = {
|
||||
message: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What last travelled along an edge. The value is whatever the producing node
|
||||
* published, shown as it was serialised.
|
||||
*/
|
||||
export function EdgeInspector({
|
||||
edge,
|
||||
flow,
|
||||
onClose,
|
||||
onUnbind,
|
||||
}: {
|
||||
edge: InspectedEdge | null
|
||||
flow: string
|
||||
onClose: () => void
|
||||
onUnbind: (message: string) => void
|
||||
}) {
|
||||
const live = useLiveValue(edge?.message)
|
||||
if (!edge) return null
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor
|
||||
style={{ position: "fixed", left: edge.x, top: edge.y }}
|
||||
className="size-0"
|
||||
/>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className="w-72 p-3"
|
||||
data-testid="edge-inspector"
|
||||
>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{displayName(flow, edge.message)}
|
||||
</p>
|
||||
|
||||
{live === undefined ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Nothing has come through yet. Run the flow to see a value here.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="mt-2 max-h-48">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{JSON.stringify(live.value, null, 2)}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{relativeTime(live.ts)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => onUnbind(edge.message)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useReactFlow } from "@xyflow/react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Play,
|
||||
Plus,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import type { ValidationIssue } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
|
||||
/**
|
||||
* The action bar, floating bottom-centre. Run is the one brand-secondary
|
||||
* affordance on this view; everything else stays quiet.
|
||||
*/
|
||||
export function FlowDock({
|
||||
issues,
|
||||
running,
|
||||
onAddNode,
|
||||
onRun,
|
||||
onFocusNode,
|
||||
}: {
|
||||
issues: ValidationIssue[]
|
||||
running: boolean
|
||||
onAddNode: () => void
|
||||
onRun: () => void
|
||||
onFocusNode: (nodeId: string) => void
|
||||
}) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={onAddNode}
|
||||
aria-label="Add node"
|
||||
data-testid="add-node"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => fitView({ duration: 300 })}
|
||||
aria-label="Fit the flow to the screen"
|
||||
>
|
||||
<Maximize2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fit to screen</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
|
||||
{issues.length > 0 ? (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 text-destructive md:h-8"
|
||||
data-testid="validation-summary"
|
||||
>
|
||||
<AlertCircle className="size-4" />
|
||||
{issues.length}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-80 p-2">
|
||||
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
Needs attention
|
||||
</p>
|
||||
<ul className="mt-1 grid gap-0.5">
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent"
|
||||
onClick={() => issue.node && onFocusNode(issue.node)}
|
||||
disabled={!issue.node}
|
||||
>
|
||||
{issue.message}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={onRun}
|
||||
disabled={running}
|
||||
data-testid="run-flow"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Connection,
|
||||
type Node as FlowCanvasNode,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
useUpdateNodeInternals,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { type FlowDef_Input, FlowsService, type NodeDef_Input } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { CommandPalette } from "./CommandPalette"
|
||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
import { FlowDock } from "./FlowDock"
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||
import { FlowTabs } from "./FlowTabs"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
import { NodePanel } from "./NodePanel"
|
||||
import "./flow.css"
|
||||
import { liveStore } from "./liveStore"
|
||||
import {
|
||||
flowQueryOptions,
|
||||
flowsQueryOptions,
|
||||
nodeTypesQueryOptions,
|
||||
useAutosave,
|
||||
} from "./queries"
|
||||
import { useFlowSocket } from "./useFlowSocket"
|
||||
|
||||
const nodeTypes = { flow: FlowNode }
|
||||
const edgeTypes = { live: LiveEdge }
|
||||
|
||||
type Rebind = {
|
||||
nodeId: string
|
||||
port: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Step a new node off any node already sitting at that spot. */
|
||||
function freePosition(
|
||||
nodes: NodeDef_Input[],
|
||||
start: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const position = { ...start }
|
||||
// Roughly a node's footprint, so a nudged node clears the one below it.
|
||||
const occupied = () =>
|
||||
nodes.some(
|
||||
(node) =>
|
||||
Math.abs((node.position?.x ?? 0) - position.x) < 220 &&
|
||||
Math.abs((node.position?.y ?? 0) - position.y) < 80,
|
||||
)
|
||||
while (occupied()) {
|
||||
position.x += 48
|
||||
position.y += 96
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
/** A name that does not collide with the nodes already on the canvas. */
|
||||
function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
|
||||
const taken = new Set(existing.map((node) => node.id))
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = index === 1 ? type : `${type}_${index}`
|
||||
if (!taken.has(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const { screenToFlowPosition, fitView } = useReactFlow()
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
||||
const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName))
|
||||
const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions())
|
||||
|
||||
const { save, flush, mutation: saving } = useAutosave(flowName)
|
||||
|
||||
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
|
||||
() => detail.definition.nodes ?? [],
|
||||
)
|
||||
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
||||
detail.definition.nodes ?? [],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
|
||||
const issues = detail.issues ?? []
|
||||
|
||||
// Keep the latest document in a ref so autosave never captures a stale copy.
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
const commit = useCallback(
|
||||
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
|
||||
const placed = nodes.map((node) => {
|
||||
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
|
||||
return canvas ? { ...node, position: canvas.position } : node
|
||||
})
|
||||
setDefinitions(placed)
|
||||
const next: FlowDef_Input = { ...detail.definition, nodes: placed }
|
||||
latest.current = next
|
||||
save(next)
|
||||
},
|
||||
[canvasNodes, detail.definition, save],
|
||||
)
|
||||
|
||||
const typeLabels = useMemo(
|
||||
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
const issuesByNode = useMemo(() => {
|
||||
const map = new Map<string, string[]>()
|
||||
for (const issue of issues) {
|
||||
if (!issue.node) continue
|
||||
const list = map.get(issue.node) ?? []
|
||||
list.push(issue.message)
|
||||
map.set(issue.node, list)
|
||||
}
|
||||
return map
|
||||
}, [issues])
|
||||
|
||||
// Canvas nodes carry the definition so the node component can render it.
|
||||
const renderedNodes = useMemo(
|
||||
() =>
|
||||
canvasNodes.map((node) => {
|
||||
const definition = definitions.find((entry) => entry.id === node.id)
|
||||
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
|
||||
return {
|
||||
...node,
|
||||
selected: node.id === selectedId,
|
||||
data: {
|
||||
definition: definition ?? { id: node.id },
|
||||
flow: flowName,
|
||||
typeLabel:
|
||||
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
|
||||
issues: nodeIssues.length,
|
||||
issueText: nodeIssues.join("\n"),
|
||||
} satisfies FlowNodeData,
|
||||
}
|
||||
}),
|
||||
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
|
||||
)
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
const key = bindingsKey(definitions)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||
const edges = useMemo(
|
||||
() => deriveEdges(definitions, flowName),
|
||||
[key, flowName],
|
||||
)
|
||||
|
||||
// Editing ports adds and removes handles. React Flow measures those once, so
|
||||
// it has to be told, or an edge to a brand-new handle never gets drawn.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
|
||||
useEffect(() => {
|
||||
updateNodeInternals(definitions.map((node) => node.id))
|
||||
}, [key, updateNodeInternals])
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }),
|
||||
onSuccess: (state) => {
|
||||
liveStore.setValues(
|
||||
Object.fromEntries(
|
||||
Object.entries(state.values ?? {}).map(([name, value]) => [
|
||||
name,
|
||||
{ value: value.value, ts: value.ts ?? null },
|
||||
]),
|
||||
),
|
||||
)
|
||||
},
|
||||
onError: () =>
|
||||
showErrorToast("The flow could not run. Check the node errors."),
|
||||
})
|
||||
|
||||
const sourceMutation = useMutation({
|
||||
mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) =>
|
||||
FlowsService.saveNodeSource({
|
||||
name: flowName,
|
||||
nodeId,
|
||||
requestBody: { code },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["flows", flowName] })
|
||||
},
|
||||
})
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: string) => {
|
||||
const id = uniqueNodeId(definitions, type)
|
||||
// Drop it where the user is looking, but never on top of another node.
|
||||
const position = freePosition(
|
||||
definitions,
|
||||
screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
}),
|
||||
)
|
||||
const node: NodeDef_Input = {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
params: {},
|
||||
requires: [],
|
||||
provides: [],
|
||||
}
|
||||
const nextDefinitions = [...definitions, node]
|
||||
const nextCanvas = [
|
||||
...canvasNodes,
|
||||
{ id, type: "flow", position, data: {} } as FlowCanvasNode,
|
||||
]
|
||||
setCanvasNodes(nextCanvas)
|
||||
commit(nextDefinitions, nextCanvas)
|
||||
setSelectedId(id)
|
||||
},
|
||||
[canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes],
|
||||
)
|
||||
|
||||
const updateNode = useCallback(
|
||||
(next: NodeDef_Input) => {
|
||||
commit(definitions.map((node) => (node.id === next.id ? next : node)))
|
||||
},
|
||||
[commit, definitions],
|
||||
)
|
||||
|
||||
const deleteNodes = useCallback(
|
||||
(ids: string[]) => {
|
||||
const remaining = definitions.filter((node) => !ids.includes(node.id))
|
||||
setCanvasNodes(canvasNodes.filter((node) => !ids.includes(node.id)))
|
||||
commit(remaining)
|
||||
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
|
||||
},
|
||||
[canvasNodes, commit, definitions, selectedId, setCanvasNodes],
|
||||
)
|
||||
|
||||
const applyBinding = useCallback(
|
||||
(nodeId: string, port: string, message: string) => {
|
||||
commit(
|
||||
definitions.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
requires: (node.requires ?? []).map((spec) =>
|
||||
portOf(spec) === port
|
||||
? { ...spec, name: message, port }
|
||||
: spec,
|
||||
),
|
||||
}
|
||||
: node,
|
||||
),
|
||||
)
|
||||
},
|
||||
[commit, definitions],
|
||||
)
|
||||
|
||||
/**
|
||||
* Dragging output to input is shorthand for "consume what that node
|
||||
* publishes": it points the input at the producer's message name.
|
||||
*/
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
const producer = definitions.find((node) => node.id === connection.source)
|
||||
const consumer = definitions.find((node) => node.id === connection.target)
|
||||
if (!producer || !consumer) return
|
||||
|
||||
const outSpec = (producer.provides ?? []).find(
|
||||
(spec) => portOf(spec) === connection.sourceHandle,
|
||||
)
|
||||
const inSpec = (consumer.requires ?? []).find(
|
||||
(spec) => portOf(spec) === connection.targetHandle,
|
||||
)
|
||||
if (!outSpec?.name || !inSpec) return
|
||||
|
||||
if (inSpec.name && inSpec.name !== outSpec.name) {
|
||||
setRebind({
|
||||
nodeId: consumer.id,
|
||||
port: portOf(inSpec),
|
||||
from: inSpec.name,
|
||||
to: outSpec.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
applyBinding(consumer.id, portOf(inSpec), outSpec.name)
|
||||
},
|
||||
[definitions, applyBinding],
|
||||
)
|
||||
|
||||
const unbind = useCallback(
|
||||
(message: string) => {
|
||||
const qualified = qualify(flowName, message)
|
||||
commit(
|
||||
definitions.map((node) => ({
|
||||
...node,
|
||||
requires: (node.requires ?? []).map((spec) =>
|
||||
qualify(flowName, spec.name ?? "") === qualified
|
||||
? { ...spec, name: "", port: portOf(spec) }
|
||||
: spec,
|
||||
),
|
||||
})),
|
||||
)
|
||||
setInspected(null)
|
||||
},
|
||||
[commit, definitions, flowName],
|
||||
)
|
||||
|
||||
const focusNode = useCallback(
|
||||
(qualifiedId: string) => {
|
||||
const id = qualifiedId.startsWith(`${flowName}.`)
|
||||
? qualifiedId.slice(flowName.length + 1)
|
||||
: qualifiedId
|
||||
fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 })
|
||||
setSelectedId(id)
|
||||
},
|
||||
[fitView, flowName],
|
||||
)
|
||||
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_event, _node, dragged) =>
|
||||
commit(definitions, mergeDragged(canvasNodes, dragged))
|
||||
}
|
||||
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
|
||||
onNodeClick={(_event, node) => setSelectedId(node.id)}
|
||||
onPaneClick={() => {
|
||||
setSelectedId(null)
|
||||
setInspected(null)
|
||||
}}
|
||||
onEdgeClick={(event, edge) => {
|
||||
setInspected({
|
||||
message: (edge.data as { message: string }).message,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={{ maxZoom: 1, padding: 0.25 }}
|
||||
minZoom={0.25}
|
||||
maxZoom={2}
|
||||
nodeDragThreshold={5}
|
||||
connectionRadius={30}
|
||||
connectOnClick
|
||||
autoPanOnConnect
|
||||
edgesReconnectable={false}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
|
||||
</ReactFlow>
|
||||
|
||||
<FlowTabs
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
/>
|
||||
|
||||
<FlowDock
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={() => {
|
||||
flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
|
||||
{definitions.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Workflow className="size-6" />
|
||||
</span>
|
||||
<p className="text-lg font-medium">This flow is empty</p>
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
Add a node to get started. Press ⌘K, or use the plus in the bar
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NodePanel
|
||||
node={selected}
|
||||
flow={flowName}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
onChange={updateNode}
|
||||
onSaveSource={(code) => {
|
||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||
}}
|
||||
onClose={() => {
|
||||
flush()
|
||||
setSelectedId(null)
|
||||
}}
|
||||
onDelete={() => selected && deleteNodes([selected.id])}
|
||||
/>
|
||||
|
||||
<EdgeInspector
|
||||
edge={inspected}
|
||||
flow={flowName}
|
||||
onClose={() => setInspected(null)}
|
||||
onUnbind={unbind}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
onRun={() => runMutation.mutate()}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(rebind)}
|
||||
onOpenChange={(open) => !open && setRebind(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change what this input reads?</DialogTitle>
|
||||
<DialogDescription>
|
||||
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
|
||||
{rebind?.to} instead?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRebind(null)}>
|
||||
Keep {rebind?.from}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
||||
setRebind(null)
|
||||
}}
|
||||
>
|
||||
Read {rebind?.to}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function mergeDragged(
|
||||
nodes: FlowCanvasNode[],
|
||||
dragged: FlowCanvasNode[],
|
||||
): FlowCanvasNode[] {
|
||||
const moved = new Map(dragged.map((node) => [node.id, node.position]))
|
||||
return nodes.map((node) =>
|
||||
moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node,
|
||||
)
|
||||
}
|
||||
|
||||
/** Seed xyflow's own node state once; it owns positions while you drag. */
|
||||
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
||||
return useNodesState<FlowCanvasNode>(
|
||||
definitions.map((node) => ({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
|
||||
data: {},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowEditor({ flowName }: { flowName: string }) {
|
||||
const navigate = useNavigate()
|
||||
const onAuthFailure = useCallback(() => {
|
||||
navigate({ to: "/login" })
|
||||
}, [navigate])
|
||||
|
||||
useFlowSocket(onAuthFailure)
|
||||
|
||||
useEffect(() => {
|
||||
return () => liveStore.reset()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
{/* Remounting per flow keeps canvas state from leaking between them. */}
|
||||
<FlowEditorInner key={flowName} flowName={flowName} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Braces,
|
||||
Clock,
|
||||
Code2,
|
||||
Database,
|
||||
Globe,
|
||||
Radio,
|
||||
} from "lucide-react"
|
||||
import { memo } from "react"
|
||||
|
||||
import type { MessageSpec, NodeDef_Input } from "@/client"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { portOf } from "./deriveEdges"
|
||||
import { useNodeStatus } from "./liveStore"
|
||||
|
||||
const NODE_ICONS = {
|
||||
python: Code2,
|
||||
mqtt: Radio,
|
||||
http: Globe,
|
||||
influxdb: Database,
|
||||
delay: Clock,
|
||||
mlp: Braces,
|
||||
} as const
|
||||
|
||||
const STATUS_STYLES = {
|
||||
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
||||
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
||||
error: { dot: "bg-destructive", label: "Failed" },
|
||||
} as const
|
||||
|
||||
export type FlowNodeData = {
|
||||
definition: NodeDef_Input
|
||||
flow: string
|
||||
typeLabel: string
|
||||
issues: number
|
||||
issueText: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Vertically distribute handles so several ports stay reachable. */
|
||||
function handleOffset(index: number, total: number): string {
|
||||
if (total <= 1) return "50%"
|
||||
const span = 60
|
||||
return `${50 - span / 2 + (span / (total - 1)) * index}%`
|
||||
}
|
||||
|
||||
function PortHandles({
|
||||
specs,
|
||||
type,
|
||||
position,
|
||||
}: {
|
||||
specs: MessageSpec[]
|
||||
type: "source" | "target"
|
||||
position: Position
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{specs.map((spec, index) => {
|
||||
const port = portOf(spec)
|
||||
return (
|
||||
<Handle
|
||||
key={`${type}-${port}`}
|
||||
id={port}
|
||||
type={type}
|
||||
position={position}
|
||||
className={cn(
|
||||
"!bg-card !border-muted-foreground/60",
|
||||
!spec.name && "unbound",
|
||||
)}
|
||||
style={{ top: handleOffset(index, specs.length) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
const { definition, flow, typeLabel, issues, issueText } =
|
||||
data as FlowNodeData
|
||||
const live = useNodeStatus(`${flow}.${definition.id}`)
|
||||
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
|
||||
|
||||
const status = live?.status === "active" ? undefined : live?.status
|
||||
const style = status
|
||||
? STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
||||
selected && "border-primary shadow-e2",
|
||||
)}
|
||||
>
|
||||
<PortHandles
|
||||
specs={definition.requires ?? []}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{definition.title || definition.id}
|
||||
</span>
|
||||
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</span>
|
||||
{style ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
className={cn("size-2 shrink-0 rounded-full", style.dot)}
|
||||
aria-label={style.label}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{live?.error ?? style.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{issues > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span 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">{issueText}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<PortHandles
|
||||
specs={definition.provides ?? []}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FlowNode = memo(FlowNodeComponent)
|
||||
@@ -0,0 +1,194 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { Check, Loader2, Plus, WifiOff } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type FlowSummary, FlowsService } 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 { Label } from "@/components/ui/label"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLiveConnection } from "./liveStore"
|
||||
import { flowKeys } from "./queries"
|
||||
|
||||
const nameSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Give the flow a name")
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]*$/,
|
||||
"Lowercase letters, digits and underscores, starting with a letter",
|
||||
),
|
||||
})
|
||||
|
||||
type NameForm = z.infer<typeof nameSchema>
|
||||
|
||||
function NewFlowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm<NameForm>({
|
||||
resolver: zodResolver(nameSchema),
|
||||
defaultValues: { name: "" },
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: NameForm) =>
|
||||
FlowsService.saveFlow({
|
||||
name: values.name,
|
||||
requestBody: { name: values.name, nodes: [], inputs: [] },
|
||||
}),
|
||||
onSuccess: (_data, values) => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
onOpenChange(false)
|
||||
form.reset()
|
||||
navigate({ to: "/flows/$flowName", params: { flowName: values.name } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New flow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Flows are small on purpose. Name this one after what it does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2 py-4">
|
||||
<Label htmlFor="flow-name">Name</Label>
|
||||
<Input
|
||||
id="flow-name"
|
||||
data-testid="flow-name-input"
|
||||
placeholder="heating"
|
||||
autoComplete="off"
|
||||
{...form.register("name")}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Creating…" : "Create flow"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow switcher, floating top-centre over the canvas. Each flow is a chip:
|
||||
* transparent at rest, filled when it is the one you are looking at.
|
||||
*/
|
||||
export function FlowTabs({
|
||||
flows,
|
||||
active,
|
||||
saving,
|
||||
}: {
|
||||
flows: FlowSummary[]
|
||||
active: string
|
||||
saving: boolean
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const connected = useLiveConnection()
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{flows.map((flow) => (
|
||||
<Link
|
||||
key={flow.name}
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flow.name }}
|
||||
className={cn(
|
||||
"shrink-0 snap-start rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
flow.name === active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{flow.title || flow.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
aria-label="New flow"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New flow</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{!connected ? (
|
||||
<WifiOff className="size-3.5" />
|
||||
) : saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!connected
|
||||
? "Reconnecting to the engine"
|
||||
: saving
|
||||
? "Saving"
|
||||
: "All changes saved"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
|
||||
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
useStore,
|
||||
} from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { FlowEdgeData } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
|
||||
/** Below this zoom the value chips would be unreadable, so they step aside. */
|
||||
const CHIP_MIN_ZOOM = 0.5
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
if (typeof value === "string") return value
|
||||
return JSON.stringify(value) ?? ""
|
||||
}
|
||||
|
||||
function LiveEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const { message } = (data ?? {}) as FlowEdgeData
|
||||
const live = useLiveValue(message)
|
||||
const zoom = useStore((state) => state.transform[2])
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
const lastTs = useRef<number | null>(null)
|
||||
|
||||
const [path, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
// Restart the stroke animation whenever a newer message lands.
|
||||
useEffect(() => {
|
||||
if (!live?.ts || live.ts === lastTs.current) return
|
||||
lastTs.current = live.ts
|
||||
setPulsing(true)
|
||||
const timer = setTimeout(() => setPulsing(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [live?.ts])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
className={cn(pulsing && "edge-live")}
|
||||
style={selected ? { stroke: "var(--primary)" } : undefined}
|
||||
/>
|
||||
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
}}
|
||||
className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card/80 px-2 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-md"
|
||||
>
|
||||
{formatValue(live.value)}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const LiveEdge = memo(LiveEdgeComponent)
|
||||
@@ -0,0 +1,54 @@
|
||||
import Editor from "@monaco-editor/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { monacoFontFamily, setupMonaco } from "./monacoSetup"
|
||||
|
||||
/**
|
||||
* The embedded Python editor. Highlighting and bracket handling only: what the
|
||||
* code actually does is checked by the engine, which reports load and run
|
||||
* errors back onto the node.
|
||||
*/
|
||||
export default function NodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setupMonaco()
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const fontFamily = useMemo(() => (ready ? monacoFontFamily() : ""), [ready])
|
||||
|
||||
if (!ready) return <Skeleton className="h-full w-full rounded-md" />
|
||||
|
||||
return (
|
||||
<Editor
|
||||
language="python"
|
||||
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
|
||||
value={value}
|
||||
onChange={(next) => onChange(next ?? "")}
|
||||
loading={<Skeleton className="h-full w-full rounded-md" />}
|
||||
options={{
|
||||
fontFamily,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
lineNumbersMinChars: 3,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
renderLineHighlight: "line",
|
||||
overviewRulerLanes: 0,
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
|
||||
import { nodeSourceQueryOptions } from "./queries"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
|
||||
|
||||
const 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 },
|
||||
},
|
||||
}
|
||||
|
||||
function PortList({
|
||||
title,
|
||||
specs,
|
||||
flow,
|
||||
emptyHint,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
specs: MessageSpec[]
|
||||
flow: string
|
||||
emptyHint: string
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||
const next = specs.map((spec, i) =>
|
||||
i === index ? { ...spec, ...patch } : spec,
|
||||
)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={SECTION}>{title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{specs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
||||
) : null}
|
||||
|
||||
{specs.map((spec, index) => (
|
||||
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={spec.name ?? ""}
|
||||
placeholder={`name in ${flow}`}
|
||||
aria-label="Message name"
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { name: event.target.value, port: "" })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) => update(index, { dtype: value as DType })}
|
||||
>
|
||||
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small form built from the node type's declared parameters. */
|
||||
function ParamsForm({
|
||||
schema,
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
schema: Record<string, unknown> | undefined
|
||||
params: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
string,
|
||||
{ type?: string; title?: string; default?: unknown }
|
||||
>
|
||||
const entries = Object.entries(properties)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const set = (key: string, value: unknown) =>
|
||||
onChange({ ...params, [key]: value })
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Settings</span>
|
||||
{entries.map(([key, property]) => {
|
||||
const value = params[key] ?? property.default ?? ""
|
||||
const label = property.title ?? key
|
||||
|
||||
if (property.type === "boolean") {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`param-${key}`}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => set(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (property.type === "object" || property.type === "array") {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric =
|
||||
property.type === "integer" || property.type === "number"
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`param-${key}`}
|
||||
className="h-8 text-sm"
|
||||
type={numeric ? "number" : "text"}
|
||||
value={String(value)}
|
||||
onChange={(event) =>
|
||||
set(
|
||||
key,
|
||||
numeric ? Number(event.target.value) : event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelBody({
|
||||
node,
|
||||
flow,
|
||||
nodeType,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
node: NodeDef_Input
|
||||
flow: string
|
||||
nodeType: NodeTypeInfo | undefined
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const hasSource = nodeType?.has_source ?? node.type === "python"
|
||||
const { data: source } = useQuery({
|
||||
...nodeSourceQueryOptions(flow, node.id),
|
||||
enabled: hasSource,
|
||||
})
|
||||
|
||||
const [code, setCode] = useState<string | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<string | null>(null)
|
||||
const save = useRef(onSaveSource)
|
||||
save.current = onSaveSource
|
||||
|
||||
const editCode = (next: string) => {
|
||||
setCode(next)
|
||||
pending.current = next
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null
|
||||
save.current(next)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Closing the panel must not lose the last keystrokes.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
if (pending.current !== null) save.current(pending.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Input
|
||||
value={node.title || node.id}
|
||||
aria-label="Node name"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) => onChange({ ...node, title: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="grid gap-5 p-4">
|
||||
<PortList
|
||||
title="Consumes"
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Node settings, floating over the canvas so the graph stays visible and live.
|
||||
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
||||
*/
|
||||
export function NodePanel({
|
||||
node,
|
||||
flow,
|
||||
nodeTypes,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
node: NodeDef_Input | null
|
||||
flow: string
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
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}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{node ? (
|
||||
<motion.aside
|
||||
key={node.id}
|
||||
variants={panelSlide}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
role="complementary"
|
||||
aria-label="Node settings"
|
||||
data-testid="node-panel"
|
||||
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"
|
||||
>
|
||||
<PanelBody
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</motion.aside>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Edge } from "@xyflow/react"
|
||||
|
||||
import type { MessageSpec, NodeDef_Input } from "@/client"
|
||||
|
||||
/** Resolve a message name against its flow: bare names belong to this flow. */
|
||||
export function qualify(flow: string, name: string): string {
|
||||
if (!name) return ""
|
||||
return name.includes(".") ? name : `${flow}.${name}`
|
||||
}
|
||||
|
||||
/** The flow a qualified message belongs to. */
|
||||
export function flowOf(message: string): string {
|
||||
return message.split(".", 1)[0]
|
||||
}
|
||||
|
||||
/** How a message reads inside its own flow. */
|
||||
export function displayName(flow: string, message: string): string {
|
||||
return message.startsWith(`${flow}.`)
|
||||
? message.slice(flow.length + 1)
|
||||
: message
|
||||
}
|
||||
|
||||
export function portOf(spec: MessageSpec): string {
|
||||
return spec.port || spec.name?.split(".").pop() || ""
|
||||
}
|
||||
|
||||
export type FlowEdgeData = {
|
||||
message: string
|
||||
flow: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn name bindings into canvas edges.
|
||||
*
|
||||
* Nodes never point at each other: a node declares the messages it consumes,
|
||||
* and every node providing that message is upstream of it. Two producers of one
|
||||
* message therefore draw two edges converging on the same input.
|
||||
*/
|
||||
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
|
||||
const producers = new Map<string, { node: string; port: string }[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const spec of node.provides ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
const list = producers.get(message) ?? []
|
||||
list.push({ node: node.id, port: portOf(spec) })
|
||||
producers.set(message, list)
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = []
|
||||
for (const node of nodes) {
|
||||
for (const spec of node.requires ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
const targetPort = portOf(spec)
|
||||
for (const producer of producers.get(message) ?? []) {
|
||||
if (producer.node === node.id) continue
|
||||
edges.push({
|
||||
id: `${producer.node}:${producer.port}->${node.id}:${targetPort}`,
|
||||
source: producer.node,
|
||||
sourceHandle: producer.port,
|
||||
target: node.id,
|
||||
targetHandle: targetPort,
|
||||
type: "live",
|
||||
data: { message, flow } satisfies FlowEdgeData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheap fingerprint of everything edges depend on, so dragging a node (which
|
||||
* replaces the array every frame) does not re-derive them.
|
||||
*/
|
||||
export function bindingsKey(nodes: NodeDef_Input[]): string {
|
||||
return nodes
|
||||
.map(
|
||||
(node) =>
|
||||
`${node.id}|${(node.requires ?? [])
|
||||
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
|
||||
.join(",")}|${(node.provides ?? [])
|
||||
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
|
||||
.join(",")}`,
|
||||
)
|
||||
.join(";")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* React Flow, routed through the design tokens.
|
||||
*
|
||||
* Scoped to `.react-flow` rather than added to index.css, so the canvas needs no
|
||||
* tokens of its own and follows the theme in both modes without a `colorMode`
|
||||
* prop. See the root DESIGN-GUIDELINES.md → Shells.
|
||||
*/
|
||||
|
||||
.react-flow {
|
||||
--xy-background-color: var(--background);
|
||||
--xy-background-pattern-dots-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 30%,
|
||||
transparent
|
||||
);
|
||||
--xy-edge-stroke: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 55%,
|
||||
transparent
|
||||
);
|
||||
--xy-edge-stroke-selected: var(--primary);
|
||||
--xy-edge-stroke-width: 1.5;
|
||||
--xy-handle-background-color: var(--card);
|
||||
--xy-handle-border-color: var(--muted-foreground);
|
||||
--xy-selection-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--primary) 8%,
|
||||
transparent
|
||||
);
|
||||
--xy-selection-border: 1px solid var(--primary);
|
||||
--xy-attribution-background-color: transparent;
|
||||
}
|
||||
|
||||
/* A message arriving lights its edge, then decays back to rest. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.react-flow__edge-path.edge-live {
|
||||
animation: edge-pulse var(--duration-slow) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
@keyframes edge-pulse {
|
||||
from {
|
||||
stroke: var(--primary);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
to {
|
||||
stroke: var(--xy-edge-stroke);
|
||||
stroke-width: var(--xy-edge-stroke-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Handles are neutral: the one brand-secondary affordance here is Run. */
|
||||
.react-flow__handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.react-flow__handle.unbound {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
.react-flow__handle-connecting {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.react-flow__node:focus-visible,
|
||||
.react-flow__node:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
/**
|
||||
* Live engine state, deliberately outside React Query.
|
||||
*
|
||||
* Values can arrive many times a second. Keeping them here, with one subscriber
|
||||
* set per key, means a changing value re-renders the chip showing it and
|
||||
* nothing else.
|
||||
*/
|
||||
|
||||
export type LiveValue = { value: unknown; ts: number | null }
|
||||
export type LiveStatus = {
|
||||
status: "active" | "error" | "running" | "success"
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const values = new Map<string, LiveValue>()
|
||||
const statuses = new Map<string, LiveStatus>()
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
let connected = false
|
||||
const connectionListeners = new Set<Listener>()
|
||||
|
||||
function notify(key: string) {
|
||||
for (const listener of listeners.get(key) ?? []) listener()
|
||||
}
|
||||
|
||||
function subscribeKey(key: string, listener: Listener) {
|
||||
let set = listeners.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
listeners.set(key, set)
|
||||
}
|
||||
set.add(listener)
|
||||
return () => {
|
||||
set.delete(listener)
|
||||
if (set.size === 0) listeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export const liveStore = {
|
||||
setValue(name: string, value: LiveValue) {
|
||||
values.set(name, value)
|
||||
notify(`value:${name}`)
|
||||
},
|
||||
setValues(entries: Record<string, LiveValue>) {
|
||||
for (const [name, value] of Object.entries(entries)) {
|
||||
values.set(name, value)
|
||||
notify(`value:${name}`)
|
||||
}
|
||||
},
|
||||
getValue(name: string) {
|
||||
return values.get(name)
|
||||
},
|
||||
setStatus(nodeId: string, status: LiveStatus) {
|
||||
statuses.set(nodeId, status)
|
||||
notify(`status:${nodeId}`)
|
||||
},
|
||||
setStatuses(
|
||||
entries: { id: string; status: string; error?: string | null }[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
statuses.set(entry.id, {
|
||||
status: entry.status as LiveStatus["status"],
|
||||
error: entry.error,
|
||||
})
|
||||
notify(`status:${entry.id}`)
|
||||
}
|
||||
},
|
||||
getStatus(nodeId: string) {
|
||||
return statuses.get(nodeId)
|
||||
},
|
||||
setConnected(next: boolean) {
|
||||
if (connected === next) return
|
||||
connected = next
|
||||
for (const listener of connectionListeners) listener()
|
||||
},
|
||||
isConnected() {
|
||||
return connected
|
||||
},
|
||||
reset() {
|
||||
for (const key of values.keys()) notify(`value:${key}`)
|
||||
values.clear()
|
||||
for (const key of statuses.keys()) notify(`status:${key}`)
|
||||
statuses.clear()
|
||||
},
|
||||
}
|
||||
|
||||
export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
|
||||
() => (name ? values.get(name) : undefined),
|
||||
)
|
||||
}
|
||||
|
||||
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
||||
() => statuses.get(nodeId),
|
||||
)
|
||||
}
|
||||
|
||||
export function useLiveConnection(): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
connectionListeners.add(listener)
|
||||
return () => connectionListeners.delete(listener)
|
||||
},
|
||||
() => connected,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Monaco, self-hosted and themed from the design tokens.
|
||||
*
|
||||
* Only the base editor and the Python grammar are pulled in; the other seventy
|
||||
* languages would triple the chunk for nothing. This module is imported by
|
||||
* NodeEditor alone, which the panel loads lazily.
|
||||
*/
|
||||
import { loader } from "@monaco-editor/react"
|
||||
import * as monaco from "monaco-editor/editor/editor.api"
|
||||
// Only Python registers itself; `basic-languages` would pull in all seventy.
|
||||
import "monaco-editor/languages/definitions/python/register"
|
||||
import editorWorker from "monaco-editor/editor/editor.worker?worker"
|
||||
|
||||
let configured = false
|
||||
|
||||
function defineThemes() {
|
||||
const themes = [
|
||||
{ id: "fluksio-light", base: "vs" as const },
|
||||
{ id: "fluksio-dark", base: "vs-dark" as const },
|
||||
]
|
||||
|
||||
for (const theme of themes) {
|
||||
const dark = theme.id.endsWith("dark")
|
||||
const background = dark ? "#1a1a1a" : "#ffffff"
|
||||
const foreground = dark ? "#f5f5f5" : "#333232"
|
||||
const muted = dark ? "#a3a3a3" : "#6b6b6b"
|
||||
const accent = dark ? "#262626" : "#f5f5f5"
|
||||
const primary = dark ? "#7ba3b8" : "#4a7189"
|
||||
const success = dark ? "#87b596" : "#5e8b6d"
|
||||
|
||||
monaco.editor.defineTheme(theme.id, {
|
||||
base: theme.base,
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: primary.slice(1) },
|
||||
{ token: "string", foreground: success.slice(1) },
|
||||
{ token: "comment", foreground: muted.slice(1), fontStyle: "italic" },
|
||||
{ token: "number", foreground: foreground.slice(1) },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": background,
|
||||
"editor.foreground": foreground,
|
||||
"editorLineNumber.foreground": muted,
|
||||
"editorLineNumber.activeForeground": foreground,
|
||||
"editor.lineHighlightBackground": accent,
|
||||
"editorCursor.foreground": primary,
|
||||
"editor.selectionBackground": `${primary}33`,
|
||||
"editorIndentGuide.background1": accent,
|
||||
"editorWidget.background": background,
|
||||
"editorGutter.background": background,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function setupMonaco(): typeof monaco {
|
||||
if (configured) return monaco
|
||||
configured = true
|
||||
|
||||
// Self-hosted: no CDN fetch, so the editor works on a LAN install.
|
||||
self.MonacoEnvironment = { getWorker: () => new editorWorker() }
|
||||
loader.config({ monaco })
|
||||
defineThemes()
|
||||
return monaco
|
||||
}
|
||||
|
||||
export const monacoFontFamily = (): string =>
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-mono")
|
||||
.trim() || "ui-monospace, monospace"
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { type FlowDef_Input, FlowsService } from "@/client"
|
||||
|
||||
export const flowKeys = {
|
||||
all: ["flows"] as const,
|
||||
detail: (name: string) => ["flows", name] as const,
|
||||
source: (name: string, nodeId: string) =>
|
||||
["flows", name, "source", nodeId] as const,
|
||||
nodeTypes: ["flows", "node-types"] as const,
|
||||
}
|
||||
|
||||
export const flowsQueryOptions = () => ({
|
||||
queryKey: flowKeys.all,
|
||||
queryFn: () => FlowsService.readFlows(),
|
||||
})
|
||||
|
||||
export const flowQueryOptions = (name: string) => ({
|
||||
queryKey: flowKeys.detail(name),
|
||||
queryFn: () => FlowsService.readFlow({ name }),
|
||||
})
|
||||
|
||||
export const nodeTypesQueryOptions = () => ({
|
||||
queryKey: flowKeys.nodeTypes,
|
||||
queryFn: () => FlowsService.readNodeTypes(),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
|
||||
export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({
|
||||
queryKey: flowKeys.source(name, nodeId),
|
||||
queryFn: () => FlowsService.readNodeSource({ name, nodeId }),
|
||||
})
|
||||
|
||||
const AUTOSAVE_DELAY = 800
|
||||
|
||||
/**
|
||||
* Saves the flow a moment after the last edit, and immediately when the editor
|
||||
* needs the server to be current (closing a panel, switching flow, running).
|
||||
*
|
||||
* Identical documents are skipped server-side, so a quiet canvas writes nothing.
|
||||
*/
|
||||
export function useAutosave(name: string): {
|
||||
save: (definition: FlowDef_Input) => void
|
||||
flush: () => void
|
||||
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
|
||||
} {
|
||||
const queryClient = useQueryClient()
|
||||
const pending = useRef<FlowDef_Input | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (definition: FlowDef_Input) =>
|
||||
FlowsService.saveFlow({ name, requestBody: definition }),
|
||||
onSuccess: (detail) => {
|
||||
// Write the server's answer straight into the cache: invalidating would
|
||||
// pull the document back out from under edits still in flight.
|
||||
queryClient.setQueryData(flowKeys.detail(name), detail)
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
|
||||
},
|
||||
})
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
const definition = pending.current
|
||||
pending.current = null
|
||||
if (definition) {
|
||||
mutation.mutate(definition)
|
||||
}
|
||||
}, [mutation])
|
||||
|
||||
const save = useCallback(
|
||||
(definition: FlowDef_Input) => {
|
||||
pending.current = definition
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(flush, AUTOSAVE_DELAY)
|
||||
},
|
||||
[flush],
|
||||
)
|
||||
|
||||
// Leaving the tab is the last chance to persist what is still queued.
|
||||
useEffect(() => {
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") flush()
|
||||
}
|
||||
document.addEventListener("visibilitychange", onHidden)
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onHidden)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}
|
||||
}, [flush])
|
||||
|
||||
return { save, flush, mutation }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { liveStore } from "./liveStore"
|
||||
|
||||
const RECONNECT_MIN = 1000
|
||||
const RECONNECT_MAX = 30000
|
||||
|
||||
type FlowEvent =
|
||||
| {
|
||||
type: "snapshot"
|
||||
values: Record<string, { value: unknown; ts: number | null }>
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
}
|
||||
| { type: "message_value"; name: string; value: unknown; ts: number }
|
||||
| { type: "node_executed"; node: string }
|
||||
| { type: "node_error"; node: string; error: string }
|
||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||
| {
|
||||
type: "pipeline_rebuilt"
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
}
|
||||
|
||||
function socketUrl(): string {
|
||||
const base = String(OpenAPI.BASE || window.location.origin)
|
||||
const url = new URL("/api/v1/flows/ws", base)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
// Browsers cannot set headers on a websocket handshake, so the token rides
|
||||
// in the query string.
|
||||
url.searchParams.set("token", localStorage.getItem("access_token") ?? "")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one socket open for the editor, feeding the live store.
|
||||
*
|
||||
* @param onAuthFailure called when the server rejects the token, so the caller
|
||||
* can send the user back to the login screen.
|
||||
*/
|
||||
export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
const socket = useRef<WebSocket | null>(null)
|
||||
const retry = useRef(RECONNECT_MIN)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
closed.current = false
|
||||
|
||||
const connect = () => {
|
||||
if (closed.current) return
|
||||
const ws = new WebSocket(socketUrl())
|
||||
socket.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
retry.current = RECONNECT_MIN
|
||||
liveStore.setConnected(true)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: FlowEvent = JSON.parse(event.data)
|
||||
switch (message.type) {
|
||||
case "snapshot":
|
||||
liveStore.setValues(message.values)
|
||||
liveStore.setStatuses(message.nodes)
|
||||
break
|
||||
case "message_value":
|
||||
liveStore.setValue(message.name, {
|
||||
value: message.value,
|
||||
ts: message.ts,
|
||||
})
|
||||
break
|
||||
case "node_executed":
|
||||
liveStore.setStatus(message.node, { status: "success" })
|
||||
break
|
||||
case "node_error":
|
||||
liveStore.setStatus(message.node, {
|
||||
status: "error",
|
||||
error: message.error,
|
||||
})
|
||||
break
|
||||
case "node_status":
|
||||
liveStore.setStatus(message.node, {
|
||||
status: message.status as "active" | "error",
|
||||
error: message.error,
|
||||
})
|
||||
break
|
||||
case "pipeline_rebuilt":
|
||||
liveStore.setStatuses(message.nodes)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
liveStore.setConnected(false)
|
||||
if (closed.current) return
|
||||
if (event.code === 1008) {
|
||||
onAuthFailure?.()
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(connect, retry.current)
|
||||
retry.current = Math.min(retry.current * 2, RECONNECT_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
closed.current = true
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
socket.current?.close()
|
||||
liveStore.setConnected(false)
|
||||
}
|
||||
}, [onAuthFailure])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Briefcase, Home, Users } from "lucide-react"
|
||||
import { Briefcase, Home, Users, Workflow } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -14,6 +14,7 @@ import { User } from "./User"
|
||||
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ export function Main({ items }: MainProps) {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive = currentPath === item.path
|
||||
const isActive =
|
||||
item.path === "/"
|
||||
? currentPath === "/"
|
||||
: currentPath.startsWith(item.path)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-e2 outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -65,6 +65,10 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-status-success: var(--status-success);
|
||||
--font-mono:
|
||||
ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -104,6 +108,7 @@
|
||||
--sidebar-accent-foreground: #333232;
|
||||
--sidebar-border: #e4e4e4;
|
||||
--sidebar-ring: #4a7189;
|
||||
--status-success: #5e8b6d;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -135,6 +140,7 @@
|
||||
--sidebar-accent-foreground: #f5f5f5;
|
||||
--sidebar-border: #2a2a2a;
|
||||
--sidebar-ring: #7ba3b8;
|
||||
--status-success: #87b596;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -14,10 +14,13 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as CanvasRouteImport } from './routes/_canvas'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
@@ -43,6 +46,10 @@ const LayoutRoute = LayoutRouteImport.update({
|
||||
id: '/_layout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CanvasRoute = CanvasRouteImport.update({
|
||||
id: '/_canvas',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -63,6 +70,16 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
|
||||
id: '/flows/',
|
||||
path: '/flows/',
|
||||
getParentRoute: () => CanvasRoute,
|
||||
} as any)
|
||||
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
||||
id: '/flows/$flowName',
|
||||
path: '/flows/$flowName',
|
||||
getParentRoute: () => CanvasRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof LayoutIndexRoute
|
||||
@@ -73,8 +90,11 @@ export interface FileRoutesByFullPath {
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/flows/': typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof LayoutIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
@@ -82,10 +102,12 @@ export interface FileRoutesByTo {
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/': typeof LayoutIndexRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/flows': typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_canvas': typeof CanvasRouteWithChildren
|
||||
'/_layout': typeof LayoutRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
@@ -95,6 +117,8 @@ export interface FileRoutesById {
|
||||
'/_layout/items': typeof LayoutItemsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/_canvas/flows/': typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -107,8 +131,11 @@ export interface FileRouteTypes {
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/settings'
|
||||
| '/flows/$flowName'
|
||||
| '/flows/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/recover-password'
|
||||
| '/reset-password'
|
||||
@@ -116,9 +143,11 @@ export interface FileRouteTypes {
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/settings'
|
||||
| '/'
|
||||
| '/flows/$flowName'
|
||||
| '/flows'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_canvas'
|
||||
| '/_layout'
|
||||
| '/login'
|
||||
| '/recover-password'
|
||||
@@ -128,9 +157,12 @@ export interface FileRouteTypes {
|
||||
| '/_layout/items'
|
||||
| '/_layout/settings'
|
||||
| '/_layout/'
|
||||
| '/_canvas/flows/$flowName'
|
||||
| '/_canvas/flows/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
CanvasRoute: typeof CanvasRouteWithChildren
|
||||
LayoutRoute: typeof LayoutRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
RecoverPasswordRoute: typeof RecoverPasswordRoute
|
||||
@@ -175,6 +207,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_canvas': {
|
||||
id: '/_canvas'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof CanvasRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout/': {
|
||||
id: '/_layout/'
|
||||
path: '/'
|
||||
@@ -203,9 +242,36 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_canvas/flows/': {
|
||||
id: '/_canvas/flows/'
|
||||
path: '/flows'
|
||||
fullPath: '/flows/'
|
||||
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
|
||||
parentRoute: typeof CanvasRoute
|
||||
}
|
||||
'/_canvas/flows/$flowName': {
|
||||
id: '/_canvas/flows/$flowName'
|
||||
path: '/flows/$flowName'
|
||||
fullPath: '/flows/$flowName'
|
||||
preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport
|
||||
parentRoute: typeof CanvasRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CanvasRouteChildren {
|
||||
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
|
||||
CanvasFlowsIndexRoute: typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
|
||||
const CanvasRouteChildren: CanvasRouteChildren = {
|
||||
CanvasFlowsFlowNameRoute: CanvasFlowsFlowNameRoute,
|
||||
CanvasFlowsIndexRoute: CanvasFlowsIndexRoute,
|
||||
}
|
||||
|
||||
const CanvasRouteWithChildren =
|
||||
CanvasRoute._addFileChildren(CanvasRouteChildren)
|
||||
|
||||
interface LayoutRouteChildren {
|
||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||
LayoutItemsRoute: typeof LayoutItemsRoute
|
||||
@@ -224,6 +290,7 @@ const LayoutRouteWithChildren =
|
||||
LayoutRoute._addFileChildren(LayoutRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
CanvasRoute: CanvasRouteWithChildren,
|
||||
LayoutRoute: LayoutRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
RecoverPasswordRoute: RecoverPasswordRoute,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
|
||||
/**
|
||||
* The full-bleed shell. Same frosted sidebar as `_layout`, but the content
|
||||
* region is the whole viewport: the flow editor floats its own chrome over the
|
||||
* canvas instead of sitting inside a padded column.
|
||||
*/
|
||||
export const Route = createFileRoute("/_canvas")({
|
||||
component: CanvasLayout,
|
||||
beforeLoad: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function CanvasLayout() {
|
||||
return (
|
||||
<SidebarProvider className="bg-card">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="bg-card">
|
||||
<main className="relative h-svh overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default CanvasLayout
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { FlowEditor } from "@/components/Flow/FlowEditor"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export const Route = createFileRoute("/_canvas/flows/$flowName")({
|
||||
component: FlowRoute,
|
||||
head: ({ params }) => ({
|
||||
meta: [{ title: `${params.flowName} - Fluksio` }],
|
||||
}),
|
||||
})
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="h-full w-full p-4">
|
||||
<Skeleton className="mx-auto h-10 w-72 rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowRoute() {
|
||||
const { flowName } = Route.useParams()
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<FlowEditor flowName={flowName} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { FlowsService } from "@/client"
|
||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export const Route = createFileRoute("/_canvas/flows/")({
|
||||
component: () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<FlowsIndex />
|
||||
</Suspense>
|
||||
),
|
||||
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
|
||||
})
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-32 w-64 rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowsIndex() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
FlowsService.saveFlow({
|
||||
name: "my_first_flow",
|
||||
requestBody: { name: "my_first_flow", nodes: [], inputs: [] },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: "my_first_flow" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// With flows around, open the first one rather than showing an empty page.
|
||||
if (flows.data.length > 0) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flows.data[0].name }}
|
||||
replace
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<span className="flex size-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Workflow className="size-7" />
|
||||
</span>
|
||||
<div className="grid gap-1">
|
||||
<h1 className="font-display text-lg font-medium">No flows yet</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
A flow is a handful of nodes passing messages to each other. Start
|
||||
with one and add nodes as you go.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending}
|
||||
data-testid="create-first-flow"
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create a flow"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* The flow editor's load-bearing behaviour: nodes are placed, connections come
|
||||
* from matching message names, and everything survives a reload because the
|
||||
* canvas autosaves.
|
||||
*/
|
||||
|
||||
const flowName = `test_flow_${Date.now().toString(36)}`
|
||||
|
||||
test.use({ storageState: "playwright/.auth/user.json" })
|
||||
|
||||
test.describe.configure({ mode: "serial" })
|
||||
|
||||
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||
|
||||
/** Write a node's source through the API; typing code is not what we test. */
|
||||
async function setNodeSource(page: Page, nodeId: string, code: string) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
||||
const response = await page.request.put(
|
||||
`${apiUrl}/api/v1/flows/${flowName}/nodes/${nodeId}/source`,
|
||||
{ headers: { Authorization: `Bearer ${token}` }, data: { code } },
|
||||
)
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function addFunctionNode(page: Page, expected: number) {
|
||||
await page.getByTestId("add-node").click()
|
||||
await page
|
||||
.getByRole("option", { name: /function/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page.locator(".react-flow__node")).toHaveCount(expected)
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
|
||||
/** Name the message on a node's first input or output port. */
|
||||
async function bindPort(
|
||||
page: Page,
|
||||
index: number,
|
||||
side: "in" | "out",
|
||||
message: string,
|
||||
) {
|
||||
await page.locator(".react-flow__node").nth(index).click()
|
||||
const panel = page.getByTestId("node-panel")
|
||||
await panel
|
||||
.getByRole("button", { name: "Add" })
|
||||
.nth(side === "in" ? 0 : 1)
|
||||
.click()
|
||||
await panel.getByLabel("Message name").fill(message)
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
|
||||
test("a flow can be created, wired up, and comes back after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/flows")
|
||||
|
||||
// Create a flow of our own so the test does not lean on existing data.
|
||||
await page.getByRole("button", { name: "New flow" }).click()
|
||||
await page.getByTestId("flow-name-input").fill(flowName)
|
||||
await page.getByRole("button", { name: "Create flow" }).click()
|
||||
await page.waitForURL(`/flows/${flowName}`)
|
||||
|
||||
await addFunctionNode(page, 1)
|
||||
await addFunctionNode(page, 2)
|
||||
|
||||
// The first node publishes "reading", the second consumes it. That match is
|
||||
// the whole connection; no edge was drawn by hand.
|
||||
await bindPort(page, 0, "out", "reading")
|
||||
await bindPort(page, 1, "in", "reading")
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(1)
|
||||
|
||||
// Autosave, then prove it by coming back fresh.
|
||||
await page.waitForTimeout(1500)
|
||||
await page.reload()
|
||||
await expect(page.locator(".react-flow__node")).toHaveCount(2)
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("running a flow puts values on its edges", async ({ page }) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
await page.waitForSelector(".react-flow__node")
|
||||
|
||||
await setNodeSource(
|
||||
page,
|
||||
"python",
|
||||
'def process(params):\n return {"reading": 42.0}\n',
|
||||
)
|
||||
await setNodeSource(
|
||||
page,
|
||||
"python_2",
|
||||
"def process(reading, params):\n return {}\n",
|
||||
)
|
||||
|
||||
await page.reload()
|
||||
await page.waitForSelector(".react-flow__edge")
|
||||
|
||||
await page.getByTestId("run-flow").click()
|
||||
await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText(
|
||||
"42",
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user