Add the connector contract, reusable nodes and per-port intervals
Connectors are the device-facing node class third parties write, so the surface they build against is versioned and documented: ConnectorNode carries a declared contract version, a polling loop that publishes only what changed and reports health around it, and parameters whose credential fields are marked x-secret so the editor offers the secrets store instead of a text box. They are found through the fluksio.node_types entry point group, with the package's own metadata as the manifest. docs/connectors/ has the contract and the authoring guide; connector-skeleton/ is a working one to copy. The controller no longer knows what any node type is: start, stop and report_health are protocol methods on Node, and the built-ins were migrated to them first, so the hooks a connector implements are the ones the engine has been driving all along. Marking a node reusable moves its source to _lib/ and points the node at it by name. Other flows instantiate it with their own ports and settings, one fix reaches all of them, and a shared source still in use cannot be deleted. Ports gained an interval: an output publishes, and an input wakes its node, at most every n seconds. State keeps the latest value, so only the delivery is skipped, and pressing Run is never throttled. Also fixes autosave sending no version on its first save of a session, which made every flow saved more than once conflict with itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7344eac262
commit
3724b68f23
@@ -343,6 +343,26 @@ export const HistoryPointSchema = {
|
||||
description: 'One numeric value a message carried, and when.'
|
||||
} as const;
|
||||
|
||||
export const LibraryNodeSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name'
|
||||
},
|
||||
used_by: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Used By'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
title: 'LibraryNode',
|
||||
description: 'A node source shared across flows, and who is using it.'
|
||||
} as const;
|
||||
|
||||
export const MessageSchema = {
|
||||
properties: {
|
||||
message: {
|
||||
@@ -398,6 +418,12 @@ export const MessageSpecSchema = {
|
||||
dtype: {
|
||||
'$ref': '#/components/schemas/DType',
|
||||
default: 'float'
|
||||
},
|
||||
interval: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
title: 'Interval',
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -408,7 +434,11 @@ export const MessageSpecSchema = {
|
||||
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.`
|
||||
:param dtype: Payload type, validated on every message that passes through.
|
||||
:param interval: Deliver at most every this many seconds; 0 is every time.
|
||||
On an output it holds back publishing, on an input it holds back waking
|
||||
the node. The value is never lost — state keeps the latest — only the
|
||||
delivery is skipped.`
|
||||
} as const;
|
||||
|
||||
export const MessageValueSchema = {
|
||||
@@ -492,6 +522,17 @@ export const NodeDef_InputSchema = {
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Provides'
|
||||
},
|
||||
source_ref: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Source Ref'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -541,6 +582,17 @@ export const NodeDef_OutputSchema = {
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Provides'
|
||||
},
|
||||
source_ref: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Source Ref'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -583,12 +635,29 @@ export const NodeStatusPublicSchema = {
|
||||
}
|
||||
],
|
||||
title: 'Error'
|
||||
},
|
||||
health: {
|
||||
type: 'string',
|
||||
enum: ['ok', 'degraded', 'down'],
|
||||
title: 'Health',
|
||||
default: 'ok'
|
||||
},
|
||||
health_detail: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Health Detail'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'NodeStatusPublic',
|
||||
description: 'Whether a node loaded, and why not.'
|
||||
description: 'Whether a node loaded, and how its connection is doing.'
|
||||
} as const;
|
||||
|
||||
export const NodeTypeInfoSchema = {
|
||||
@@ -614,6 +683,17 @@ export const NodeTypeInfoSchema = {
|
||||
type: 'boolean',
|
||||
title: 'Has Source',
|
||||
default: false
|
||||
},
|
||||
plugin: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Plugin'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -733,6 +813,18 @@ export const SecretValueSchema = {
|
||||
title: 'SecretValue'
|
||||
} as const;
|
||||
|
||||
export const ShareRequestSchema = {
|
||||
properties: {
|
||||
lib_name: {
|
||||
type: 'string',
|
||||
title: 'Lib Name'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['lib_name'],
|
||||
title: 'ShareRequest'
|
||||
} as const;
|
||||
|
||||
export const TokenSchema = {
|
||||
properties: {
|
||||
access_token: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, 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 {
|
||||
/**
|
||||
@@ -32,6 +32,40 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Library
|
||||
* The node sources shared across flows, and which nodes use each.
|
||||
* @returns LibraryNode Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readLibrary(): CancelablePromise<FlowsReadLibraryResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/library'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Shared Node
|
||||
* Remove a shared source, as long as no flow still runs it.
|
||||
* @param data The data for the request.
|
||||
* @param data.libName
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteSharedNode(data: FlowsDeleteSharedNodeData): CancelablePromise<FlowsDeleteSharedNodeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/flows/library/{lib_name}',
|
||||
path: {
|
||||
lib_name: data.libName
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Flow
|
||||
* Read one flow, with the state of its nodes.
|
||||
@@ -202,6 +236,10 @@ export class FlowsService {
|
||||
* The answer comes from compiling the code rather than from the running
|
||||
* pipeline: a draft is not deployed, and compiling is both faster and more
|
||||
* precise about what the author just typed.
|
||||
*
|
||||
* A shared node writes to the library, so the fix reaches every flow using
|
||||
* it — and that one is live immediately rather than waiting for a publish,
|
||||
* because the copy is not any single flow's to hold back.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
@@ -225,6 +263,55 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Node
|
||||
* Move this node's code into the library so other flows can run it too.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @param data.requestBody
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static shareNode(data: FlowsShareNodeData): CancelablePromise<FlowsShareNodeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/share',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare Node
|
||||
* Take a private copy of the shared code back into this flow.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static unshareNode(data: FlowsUnshareNodeData): CancelablePromise<FlowsUnshareNodeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/unshare',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Flow
|
||||
* Let the engine run this flow again.
|
||||
|
||||
@@ -105,6 +105,14 @@ export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node source shared across flows, and who is using it.
|
||||
*/
|
||||
export type LibraryNode = {
|
||||
name: string;
|
||||
used_by?: Array<(string)>;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
message: string;
|
||||
};
|
||||
@@ -129,11 +137,16 @@ export type MessageHistory = {
|
||||
* :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.
|
||||
* :param interval: Deliver at most every this many seconds; 0 is every time.
|
||||
* On an output it holds back publishing, on an input it holds back waking
|
||||
* the node. The value is never lost — state keeps the latest — only the
|
||||
* delivery is skipped.
|
||||
*/
|
||||
export type MessageSpec = {
|
||||
name?: string;
|
||||
port?: string;
|
||||
dtype?: DType;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,6 +175,7 @@ export type NodeDef_Input = {
|
||||
};
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
source_ref?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,6 +191,7 @@ export type NodeDef_Output = {
|
||||
};
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
source_ref?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,14 +202,18 @@ export type NodeSource = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a node loaded, and why not.
|
||||
* Whether a node loaded, and how its connection is doing.
|
||||
*/
|
||||
export type NodeStatusPublic = {
|
||||
id: string;
|
||||
status?: string;
|
||||
error?: (string | null);
|
||||
health?: 'ok' | 'degraded' | 'down';
|
||||
health_detail?: (string | null);
|
||||
};
|
||||
|
||||
export type health = 'ok' | 'degraded' | 'down';
|
||||
|
||||
/**
|
||||
* A node type the editor can offer, with its parameter schema.
|
||||
*/
|
||||
@@ -206,6 +225,7 @@ export type NodeTypeInfo = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
has_source?: boolean;
|
||||
plugin?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -246,6 +266,10 @@ export type SecretValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ShareRequest = {
|
||||
lib_name: string;
|
||||
};
|
||||
|
||||
export type Token = {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
@@ -332,6 +356,14 @@ export type FlowsReadFlowsResponse = (FlowsPublic);
|
||||
|
||||
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
|
||||
|
||||
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
|
||||
|
||||
export type FlowsDeleteSharedNodeData = {
|
||||
libName: string;
|
||||
};
|
||||
|
||||
export type FlowsDeleteSharedNodeResponse = (Message);
|
||||
|
||||
export type FlowsReadFlowData = {
|
||||
name: string;
|
||||
};
|
||||
@@ -386,6 +418,21 @@ export type FlowsSaveNodeSourceData = {
|
||||
|
||||
export type FlowsSaveNodeSourceResponse = (NodeStatusPublic);
|
||||
|
||||
export type FlowsShareNodeData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
requestBody: ShareRequest;
|
||||
};
|
||||
|
||||
export type FlowsShareNodeResponse = (FlowDetail);
|
||||
|
||||
export type FlowsUnshareNodeData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export type FlowsUnshareNodeResponse = (FlowDetail);
|
||||
|
||||
export type FlowsStartFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { libraryQueryOptions } from "./queries"
|
||||
|
||||
/**
|
||||
* ⌘K: add a node, jump to another flow, or run the current one, without
|
||||
@@ -21,6 +23,7 @@ export function CommandPalette({
|
||||
nodeTypes,
|
||||
flows,
|
||||
onAddNode,
|
||||
onAddSharedNode,
|
||||
onRun,
|
||||
}: {
|
||||
open: boolean
|
||||
@@ -28,9 +31,14 @@ export function CommandPalette({
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
flows: FlowSummary[]
|
||||
onAddNode: (type: string) => void
|
||||
onAddSharedNode: (libName: string) => void
|
||||
onRun: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { data: libraryData } = useQuery({
|
||||
...libraryQueryOptions(),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -48,6 +56,8 @@ export function CommandPalette({
|
||||
// dialog outright is deterministic; the palette does not need to fade out.
|
||||
if (!open) return null
|
||||
|
||||
const library = libraryData ?? []
|
||||
|
||||
const close = (action: () => void) => {
|
||||
onOpenChange(false)
|
||||
action()
|
||||
@@ -81,6 +91,28 @@ export function CommandPalette({
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{library.length > 0 ? (
|
||||
<CommandGroup heading="Reusable nodes">
|
||||
{library.map((entry) => (
|
||||
<CommandItem
|
||||
key={entry.name}
|
||||
value={`shared ${entry.name}`}
|
||||
onSelect={() => close(() => onAddSharedNode(entry.name))}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-mono">{entry.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(entry.used_by ?? []).length === 1
|
||||
? "Used once"
|
||||
: `Used ${(entry.used_by ?? []).length} times`}
|
||||
, code shared with every flow using it
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{flows.length > 0 ? (
|
||||
<CommandGroup heading="Flows">
|
||||
{flows.map((flow) => (
|
||||
|
||||
@@ -453,8 +453,8 @@ function FlowEditorInner({
|
||||
})
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: string) => {
|
||||
const id = uniqueNodeId(definitions, type)
|
||||
(type: string, sourceRef?: string) => {
|
||||
const id = uniqueNodeId(definitions, sourceRef ?? type)
|
||||
// Drop it where the user is looking, but never on top of another node.
|
||||
const position = freePosition(
|
||||
definitions,
|
||||
@@ -470,6 +470,9 @@ function FlowEditorInner({
|
||||
params: {},
|
||||
requires: [],
|
||||
provides: [],
|
||||
// A shared node brings the code; the ports and settings are this
|
||||
// flow's own.
|
||||
...(sourceRef ? { source_ref: sourceRef } : {}),
|
||||
}
|
||||
const nextDefinitions = [...definitions, node]
|
||||
const nextCanvas = [
|
||||
@@ -809,6 +812,9 @@ function FlowEditorInner({
|
||||
onSaveSource={(code) => {
|
||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||
}}
|
||||
// Sharing rewrites the stored document, so the canvas takes the
|
||||
// server's copy rather than keeping its own.
|
||||
onShared={onReload}
|
||||
onClose={() => {
|
||||
flush()
|
||||
setEditorExpanded(false)
|
||||
@@ -830,6 +836,7 @@ function FlowEditorInner({
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
onAddSharedNode={(libName) => addNode("python", libName)}
|
||||
onRun={() => runMutation.mutate()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Maximize2, Minimize2, X } from "lucide-react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||
import {
|
||||
type DType,
|
||||
FlowsService,
|
||||
type MessageSpec,
|
||||
type NodeDef_Input,
|
||||
type NodeTypeInfo,
|
||||
} from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
@@ -22,15 +28,24 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MessageSparkline } from "./MessageSparkline"
|
||||
import { nodeSourceQueryOptions } from "./queries"
|
||||
import {
|
||||
flowKeys,
|
||||
libraryQueryOptions,
|
||||
nodeSourceQueryOptions,
|
||||
secretsQueryOptions,
|
||||
} from "./queries"
|
||||
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
|
||||
|
||||
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
|
||||
const NO_SECRET = "__none__"
|
||||
|
||||
const SECTION = PANEL_SECTION
|
||||
|
||||
/**
|
||||
@@ -208,6 +223,19 @@ function PortList({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={spec.interval ? String(spec.interval) : ""}
|
||||
placeholder="∞"
|
||||
aria-label="Deliver at most every n seconds"
|
||||
title="Deliver at most every n seconds; empty is every time"
|
||||
className="h-8 w-16 text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { interval: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -235,6 +263,8 @@ function ParamsForm({
|
||||
params: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const { data: secretList } = useQuery(secretsQueryOptions())
|
||||
const secrets = secretList?.data ?? []
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
string,
|
||||
{
|
||||
@@ -242,6 +272,7 @@ function ParamsForm({
|
||||
title?: string
|
||||
description?: string
|
||||
default?: unknown
|
||||
"x-secret"?: boolean
|
||||
}
|
||||
>
|
||||
const entries = Object.entries(properties)
|
||||
@@ -272,6 +303,41 @@ function ParamsForm({
|
||||
)
|
||||
}
|
||||
|
||||
// A credential is stored once and referenced, so it never ends up in
|
||||
// flow.json where the whole team can read it.
|
||||
if (property["x-secret"]) {
|
||||
const reference = (params[key] ?? null) as { $secret?: string } | null
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Select
|
||||
value={reference?.$secret ?? ""}
|
||||
onValueChange={(name) =>
|
||||
set(key, name === NO_SECRET ? null : { $secret: name })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
|
||||
<SelectValue placeholder="Pick a stored secret" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_SECRET}>None</SelectItem>
|
||||
{secrets.map((secret) => (
|
||||
<SelectItem key={secret} value={secret}>
|
||||
{secret}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{secrets.length
|
||||
? property.description ||
|
||||
"Stored in the secrets store, never in the flow file."
|
||||
: "No secrets stored yet."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (property.type === "object" || property.type === "array") {
|
||||
return null
|
||||
}
|
||||
@@ -307,6 +373,108 @@ function ParamsForm({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharing a node moves its code to the library, where other flows can point at
|
||||
* it. Each flow keeps its own ports and settings; only the code is common, so
|
||||
* one fix reaches all of them.
|
||||
*/
|
||||
function SharingSection({
|
||||
flow,
|
||||
node,
|
||||
onShared,
|
||||
}: {
|
||||
flow: string
|
||||
node: NodeDef_Input
|
||||
onShared: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const { data: library } = useQuery(libraryQueryOptions())
|
||||
const [name, setName] = useState("")
|
||||
|
||||
const shared = node.source_ref
|
||||
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
|
||||
|
||||
const done = () => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.library })
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
|
||||
onShared()
|
||||
}
|
||||
|
||||
const share = useMutation({
|
||||
mutationFn: (libName: string) =>
|
||||
FlowsService.shareNode({
|
||||
name: flow,
|
||||
nodeId: node.id,
|
||||
requestBody: { lib_name: libName },
|
||||
}),
|
||||
onSuccess: done,
|
||||
onError: () =>
|
||||
showErrorToast("That name is taken, or is not a valid name."),
|
||||
})
|
||||
|
||||
const unshare = useMutation({
|
||||
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
|
||||
onSuccess: done,
|
||||
onError: () => showErrorToast("The node could not be unshared."),
|
||||
})
|
||||
|
||||
if (shared) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className={SECTION}>Shared</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Runs <span className="font-mono">{shared}</span> from the library
|
||||
{usages.length > 1
|
||||
? `, along with ${usages.length - 1} other node${
|
||||
usages.length === 2 ? "" : "s"
|
||||
}`
|
||||
: ""}
|
||||
. Editing the code here changes it everywhere.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
disabled={unshare.isPending}
|
||||
onClick={() => unshare.mutate()}
|
||||
data-testid="unshare-node"
|
||||
>
|
||||
Keep a private copy
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className={SECTION}>Reuse</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Move this node's code to the library so other flows can run it too.
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="read_temperature"
|
||||
aria-label="Shared name"
|
||||
autoComplete="off"
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!name || share.isPending}
|
||||
onClick={() => share.mutate(name)}
|
||||
data-testid="share-node"
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelBody({
|
||||
node,
|
||||
flow,
|
||||
@@ -316,6 +484,7 @@ function PanelBody({
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
onSaveSource,
|
||||
onShared,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
node: NodeDef_Input
|
||||
@@ -326,6 +495,7 @@ function PanelBody({
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onShared: () => void
|
||||
onToggleExpand: () => void
|
||||
}) {
|
||||
const hasSource = nodeType?.has_source ?? node.type === "python"
|
||||
@@ -387,12 +557,17 @@ function PanelBody({
|
||||
params={node.params ?? {}}
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
/>
|
||||
{hasSource ? (
|
||||
<SharingSection flow={flow} node={node} onShared={onShared} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSource ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={SECTION}>Code</span>
|
||||
<span className={SECTION}>
|
||||
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -440,6 +615,7 @@ export function NodePanel({
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
onSaveSource,
|
||||
onShared,
|
||||
onToggleExpand,
|
||||
onClose,
|
||||
onDelete,
|
||||
@@ -452,6 +628,7 @@ export function NodePanel({
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onShared: () => void
|
||||
onToggleExpand: () => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
@@ -506,6 +683,7 @@ export function NodePanel({
|
||||
onChange={onChange}
|
||||
onRenameMessage={onRenameMessage}
|
||||
onSaveSource={onSaveSource}
|
||||
onShared={onShared}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
} from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ApiError, type FlowDef_Input, FlowsService } from "@/client"
|
||||
import {
|
||||
ApiError,
|
||||
type FlowDef_Input,
|
||||
FlowsService,
|
||||
SecretsService,
|
||||
} from "@/client"
|
||||
|
||||
export const flowKeys = {
|
||||
all: ["flows"] as const,
|
||||
@@ -15,6 +20,7 @@ export const flowKeys = {
|
||||
history: (name: string, message: string) =>
|
||||
["flows", name, "history", message] as const,
|
||||
nodeTypes: ["flows", "node-types"] as const,
|
||||
library: ["flows", "library"] as const,
|
||||
}
|
||||
|
||||
export const flowsQueryOptions = () => ({
|
||||
@@ -27,6 +33,18 @@ export const flowQueryOptions = (name: string) => ({
|
||||
queryFn: () => FlowsService.readFlow({ name }),
|
||||
})
|
||||
|
||||
/** Names of the stored secrets, for pointing a credential parameter at one. */
|
||||
export const secretsQueryOptions = () => ({
|
||||
queryKey: ["secrets"] as const,
|
||||
queryFn: () => SecretsService.readSecrets(),
|
||||
})
|
||||
|
||||
/** Node sources shared across flows, with the nodes using each. */
|
||||
export const libraryQueryOptions = () => ({
|
||||
queryKey: flowKeys.library,
|
||||
queryFn: () => FlowsService.readLibrary(),
|
||||
})
|
||||
|
||||
export const nodeTypesQueryOptions = () => ({
|
||||
queryKey: flowKeys.nodeTypes,
|
||||
queryFn: () => FlowsService.readNodeTypes(),
|
||||
@@ -107,7 +125,14 @@ export function useAutosave(name: string): {
|
||||
mutationFn: (definition: FlowDef_Input) =>
|
||||
FlowsService.saveFlow({
|
||||
name,
|
||||
requestBody: { ...definition, version: version.current ?? undefined },
|
||||
// The ref holds the freshest version this client has seen; before the
|
||||
// first save that is the one the document was loaded with. Sending
|
||||
// `undefined` would drop the field and let the server read the default,
|
||||
// which conflicts with every flow saved more than once.
|
||||
requestBody: {
|
||||
...definition,
|
||||
version: version.current ?? definition.version ?? 1,
|
||||
},
|
||||
}),
|
||||
onMutate: (definition) => {
|
||||
inFlight.current = true
|
||||
|
||||
Reference in New Issue
Block a user