Run python nodes out of process, with modules of their own
User code no longer execs in the engine. A pool of persistent worker subprocesses speaks one JSON object per line; the controller installs a proxy as the node's function, so every execution path funnels through it and the pipeline is untouched. A crash costs one subprocess, a per-node timeout is a kill, and cancelling from the canvas is that same kill. The workers run a venv of the user's own on the data volume, filled from a pip manifest versioned beside the flows. Applying it retires the workers and rebuilds, so a package lands without restarting the engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
@@ -27,6 +27,36 @@ export const AlertsConfigSchema = {
|
||||
description: 'The whole alerting setup, as stored and as the API sees it.'
|
||||
} as const;
|
||||
|
||||
export const ApplyRequestSchema = {
|
||||
properties: {
|
||||
requirements: {
|
||||
type: 'string',
|
||||
title: 'Requirements',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'ApplyRequest',
|
||||
description: 'A pip manifest, one requirement per line.'
|
||||
} as const;
|
||||
|
||||
export const ApplyResultSchema = {
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean',
|
||||
title: 'Ok'
|
||||
},
|
||||
output: {
|
||||
type: 'string',
|
||||
title: 'Output',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['ok'],
|
||||
title: 'ApplyResult'
|
||||
} as const;
|
||||
|
||||
export const Body_login_login_access_tokenSchema = {
|
||||
properties: {
|
||||
grant_type: {
|
||||
@@ -890,6 +920,58 @@ export const MessagesPublicSchema = {
|
||||
title: 'MessagesPublic'
|
||||
} as const;
|
||||
|
||||
export const ModulePackageSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name'
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
title: 'Version'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['name', 'version'],
|
||||
title: 'ModulePackage',
|
||||
description: 'One package installed in the venv node code runs on.'
|
||||
} as const;
|
||||
|
||||
export const ModulesInfoSchema = {
|
||||
properties: {
|
||||
python_version: {
|
||||
type: 'string',
|
||||
title: 'Python Version',
|
||||
default: ''
|
||||
},
|
||||
venv_path: {
|
||||
type: 'string',
|
||||
title: 'Venv Path',
|
||||
default: ''
|
||||
},
|
||||
requirements: {
|
||||
type: 'string',
|
||||
title: 'Requirements',
|
||||
default: ''
|
||||
},
|
||||
packages: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/ModulePackage'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Packages'
|
||||
},
|
||||
applied: {
|
||||
type: 'boolean',
|
||||
title: 'Applied',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'ModulesInfo',
|
||||
description: 'The venv node code imports from, and the manifest that describes it.'
|
||||
} as const;
|
||||
|
||||
export const NewPasswordSchema = {
|
||||
properties: {
|
||||
token: {
|
||||
@@ -960,6 +1042,19 @@ export const NodeDef_InputSchema = {
|
||||
}
|
||||
],
|
||||
title: 'Source Ref'
|
||||
},
|
||||
timeout: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'number',
|
||||
exclusiveMinimum: 0
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Timeout',
|
||||
description: "Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running."
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -1020,6 +1115,19 @@ export const NodeDef_OutputSchema = {
|
||||
}
|
||||
],
|
||||
title: 'Source Ref'
|
||||
},
|
||||
timeout: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'number',
|
||||
exclusiveMinimum: 0
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Timeout',
|
||||
description: "Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running."
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, 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, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, 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, UtilsHealthResponse } from './types.gen';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, 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, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, 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, UtilsHealthResponse } from './types.gen';
|
||||
|
||||
export class AlertsService {
|
||||
/**
|
||||
@@ -670,6 +670,32 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Node
|
||||
* Stop a node that is running right now, by killing the worker running it.
|
||||
*
|
||||
* Idempotent on purpose: by the time a click reaches here the node may well
|
||||
* have finished, and that is the outcome that was asked for.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static cancelNode(data: FlowsCancelNodeData): CancelablePromise<FlowsCancelNodeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/nodes/{node_id}/cancel',
|
||||
path: {
|
||||
name: data.name,
|
||||
node_id: data.nodeId
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Flow State
|
||||
* The last value seen on every message of this flow.
|
||||
@@ -878,6 +904,44 @@ export class MessagesService {
|
||||
}
|
||||
}
|
||||
|
||||
export class ModulesService {
|
||||
/**
|
||||
* Read Modules
|
||||
* What node code can import, and whether it matches the manifest.
|
||||
* @returns ModulesInfo Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readModules(): CancelablePromise<ModulesReadModulesResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/modules/'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Modules
|
||||
* Install exactly these requirements, then hand them to the workers.
|
||||
*
|
||||
* A manifest that does not resolve changes nothing: the venv is left as it
|
||||
* was and the stored manifest is only written once the install succeeded.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns ApplyResult Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static applyModules(data: ModulesApplyModulesData): CancelablePromise<ModulesApplyModulesResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/modules/apply',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OauthService {
|
||||
/**
|
||||
* Register Client
|
||||
|
||||
@@ -43,6 +43,18 @@ export type app__flow__schemas__MessageValue = {
|
||||
ts?: (number | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* A pip manifest, one requirement per line.
|
||||
*/
|
||||
export type ApplyRequest = {
|
||||
requirements?: string;
|
||||
};
|
||||
|
||||
export type ApplyResult = {
|
||||
ok: boolean;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
export type Body_login_login_access_token = {
|
||||
grant_type?: (string | null);
|
||||
username: string;
|
||||
@@ -308,6 +320,25 @@ export type MessagesPublic = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One package installed in the venv node code runs on.
|
||||
*/
|
||||
export type ModulePackage = {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The venv node code imports from, and the manifest that describes it.
|
||||
*/
|
||||
export type ModulesInfo = {
|
||||
python_version?: string;
|
||||
venv_path?: string;
|
||||
requirements?: string;
|
||||
packages?: Array<ModulePackage>;
|
||||
applied?: boolean;
|
||||
};
|
||||
|
||||
export type NewPassword = {
|
||||
token: string;
|
||||
new_password: string;
|
||||
@@ -327,6 +358,10 @@ export type NodeDef_Input = {
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
source_ref?: (string | null);
|
||||
/**
|
||||
* Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running.
|
||||
*/
|
||||
timeout?: (number | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -343,6 +378,10 @@ export type NodeDef_Output = {
|
||||
requires?: Array<MessageSpec>;
|
||||
provides?: Array<MessageSpec>;
|
||||
source_ref?: (string | null);
|
||||
/**
|
||||
* Seconds this node's code may run before it is stopped. Above 60 the engine may deliver its work again while it is still running.
|
||||
*/
|
||||
timeout?: (number | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -805,6 +844,13 @@ export type FlowsTriggerNodeData = {
|
||||
|
||||
export type FlowsTriggerNodeResponse = (FlowStatePublic);
|
||||
|
||||
export type FlowsCancelNodeData = {
|
||||
name: string;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export type FlowsCancelNodeResponse = (Message);
|
||||
|
||||
export type FlowsReadFlowStateData = {
|
||||
name: string;
|
||||
};
|
||||
@@ -859,6 +905,14 @@ export type MessagesReadMessageHistoryData = {
|
||||
|
||||
export type MessagesReadMessageHistoryResponse = (MessagePoints);
|
||||
|
||||
export type ModulesReadModulesResponse = (ModulesInfo);
|
||||
|
||||
export type ModulesApplyModulesData = {
|
||||
requestBody: ApplyRequest;
|
||||
};
|
||||
|
||||
export type ModulesApplyModulesResponse = (ApplyResult);
|
||||
|
||||
export type OauthRegisterClientData = {
|
||||
requestBody: OAuthClientRegister;
|
||||
};
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Radio,
|
||||
Shuffle,
|
||||
Split,
|
||||
Square,
|
||||
Terminal,
|
||||
Timer,
|
||||
} from "lucide-react"
|
||||
import { memo } from "react"
|
||||
|
||||
import type { MessageSpec, NodeDef_Input } from "@/client"
|
||||
import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -157,6 +158,32 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
{typeLabel}
|
||||
</span>
|
||||
</span>
|
||||
{status === "running" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Stop this node"
|
||||
data-testid="node-cancel"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
// Best effort by nature: it may well have finished between
|
||||
// the render and the click, which is the outcome asked for.
|
||||
FlowsService.cancelNode({
|
||||
name: flow,
|
||||
nodeId: definition.id,
|
||||
}).catch(() => {})
|
||||
}}
|
||||
>
|
||||
<Square />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop this node</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{status === "error" && onShowLogs ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -934,6 +934,32 @@ function PanelBody({
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
/>
|
||||
) : null}
|
||||
{hasSource ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="node-timeout" className={SECTION}>
|
||||
Timeout
|
||||
</Label>
|
||||
<Input
|
||||
id="node-timeout"
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
className="h-8 text-sm"
|
||||
placeholder="30 (default)"
|
||||
value={node.timeout ? String(node.timeout) : ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...node,
|
||||
timeout: Number(event.target.value) || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Seconds this code may run before it is stopped. Above 60 the
|
||||
engine may deliver the same work again while it is still running.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{hasSource ? (
|
||||
<SharingSection flow={flow} node={node} onShared={onShared} />
|
||||
) : null}
|
||||
|
||||
@@ -26,6 +26,7 @@ type FlowEvent =
|
||||
ts: number
|
||||
source?: ValueSource
|
||||
}
|
||||
| { type: "node_started"; node: string }
|
||||
| { type: "node_executed"; node: string; outputs: number }
|
||||
| { type: "node_error"; node: string; error: string }
|
||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||
@@ -97,6 +98,9 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
source: message.source,
|
||||
})
|
||||
break
|
||||
case "node_started":
|
||||
liveStore.setStatus(message.node, { status: "running" })
|
||||
break
|
||||
case "node_executed":
|
||||
liveStore.setStatus(message.node, { status: "success" })
|
||||
if (message.outputs > 0) liveStore.recordEmit(message.node)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Package,
|
||||
Settings,
|
||||
Users,
|
||||
Workflow,
|
||||
@@ -28,6 +29,7 @@ const baseItems: Item[] = [
|
||||
// Both are engine-wide operator settings rather than personal ones, so they
|
||||
// sit here and not among the per-user tabs under Settings.
|
||||
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
|
||||
{ icon: Package, title: "Modules", path: "/modules" },
|
||||
{ icon: Bell, title: "Alerts", path: "/alerts" },
|
||||
]
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Route as ViewNameRouteImport } from './routes/view.$name'
|
||||
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
|
||||
import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
|
||||
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
|
||||
@@ -80,6 +81,11 @@ const LayoutSecretsRoute = LayoutSecretsRouteImport.update({
|
||||
path: '/secrets',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutModulesRoute = LayoutModulesRouteImport.update({
|
||||
id: '/modules',
|
||||
path: '/modules',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutAlertsRoute = LayoutAlertsRouteImport.update({
|
||||
id: '/alerts',
|
||||
path: '/alerts',
|
||||
@@ -119,6 +125,7 @@ export interface FileRoutesByFullPath {
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/alerts': typeof LayoutAlertsRoute
|
||||
'/modules': typeof LayoutModulesRoute
|
||||
'/secrets': typeof LayoutSecretsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
@@ -136,6 +143,7 @@ export interface FileRoutesByTo {
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/alerts': typeof LayoutAlertsRoute
|
||||
'/modules': typeof LayoutModulesRoute
|
||||
'/secrets': typeof LayoutSecretsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
@@ -155,6 +163,7 @@ export interface FileRoutesById {
|
||||
'/signup': typeof SignupRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/alerts': typeof LayoutAlertsRoute
|
||||
'/_layout/modules': typeof LayoutModulesRoute
|
||||
'/_layout/secrets': typeof LayoutSecretsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/alerts'
|
||||
| '/modules'
|
||||
| '/secrets'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
@@ -192,6 +202,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/alerts'
|
||||
| '/modules'
|
||||
| '/secrets'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
@@ -210,6 +221,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/alerts'
|
||||
| '/_layout/modules'
|
||||
| '/_layout/secrets'
|
||||
| '/_layout/settings'
|
||||
| '/oauth/authorize'
|
||||
@@ -311,6 +323,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutSecretsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/modules': {
|
||||
id: '/_layout/modules'
|
||||
path: '/modules'
|
||||
fullPath: '/modules'
|
||||
preLoaderRoute: typeof LayoutModulesRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/alerts': {
|
||||
id: '/_layout/alerts'
|
||||
path: '/alerts'
|
||||
@@ -372,6 +391,7 @@ const CanvasRouteWithChildren =
|
||||
interface LayoutRouteChildren {
|
||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||
LayoutAlertsRoute: typeof LayoutAlertsRoute
|
||||
LayoutModulesRoute: typeof LayoutModulesRoute
|
||||
LayoutSecretsRoute: typeof LayoutSecretsRoute
|
||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||
@@ -382,6 +402,7 @@ interface LayoutRouteChildren {
|
||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||
LayoutAdminRoute: LayoutAdminRoute,
|
||||
LayoutAlertsRoute: LayoutAlertsRoute,
|
||||
LayoutModulesRoute: LayoutModulesRoute,
|
||||
LayoutSecretsRoute: LayoutSecretsRoute,
|
||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||
LayoutIndexRoute: LayoutIndexRoute,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Package } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { type ApiError, ModulesService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
export const Route = createFileRoute("/_layout/modules")({
|
||||
component: Modules,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "Modules - Fluksio",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const modulesKey = ["modules"]
|
||||
|
||||
const SECTION =
|
||||
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||
|
||||
function Modules() {
|
||||
const { data } = useQuery({
|
||||
queryKey: modulesKey,
|
||||
queryFn: () => ModulesService.readModules(),
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
// Null until the field is touched, so what is stored shows through until
|
||||
// someone actually starts editing it.
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [output, setOutput] = useState("")
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: (requirements: string) =>
|
||||
ModulesService.applyModules({ requestBody: { requirements } }),
|
||||
onSuccess: (result) => {
|
||||
showSuccessToast("Modules installed")
|
||||
setOutput(result.output ?? "")
|
||||
setDraft(null)
|
||||
queryClient.invalidateQueries({ queryKey: modulesKey })
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
// The 400 detail is uv's own output — several lines of resolver
|
||||
// reasoning, which belongs in the pane rather than in a toast.
|
||||
const detail = (error.body as { detail?: string } | undefined)?.detail
|
||||
if (detail) {
|
||||
setOutput(detail)
|
||||
showErrorToast("Those requirements could not be installed")
|
||||
} else {
|
||||
handleError.call(showErrorToast, error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const stored = data?.requirements ?? ""
|
||||
const requirements = draft ?? stored
|
||||
const packages = data?.packages ?? []
|
||||
const dirty = requirements !== stored
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
<h1 className="text-2xl">Modules</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The Python packages your function nodes can import. They are installed
|
||||
into an environment of their own, separate from the engine's, so a
|
||||
version you pin here is the one your code gets. The list is kept with
|
||||
your flows, so a rebuilt deployment installs the same set again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h2 className={SECTION}>Requirements</h2>
|
||||
<textarea
|
||||
className="min-h-[160px] w-full rounded-md border border-border bg-card p-3 font-mono text-sm shadow-e1 outline-none focus-visible:border-primary"
|
||||
spellCheck={false}
|
||||
aria-label="Requirements"
|
||||
data-testid="requirements"
|
||||
placeholder={"requests==2.32.3\npandas>=2.2"}
|
||||
value={requirements}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
disabled={apply.isPending}
|
||||
data-testid="apply-modules"
|
||||
onClick={() => apply.mutate(requirements)}
|
||||
>
|
||||
<Package />
|
||||
{apply.isPending ? "Installing" : "Apply"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dirty
|
||||
? "Not applied yet."
|
||||
: data?.applied
|
||||
? "Installed and in step."
|
||||
: "What is installed does not match this list."}
|
||||
</span>
|
||||
</div>
|
||||
{output ? (
|
||||
<pre
|
||||
className="max-h-64 overflow-auto rounded-md border border-border bg-card p-3 font-mono text-xs shadow-e1"
|
||||
data-testid="modules-output"
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<h2 className={SECTION}>Installed</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data
|
||||
? `Python ${data.python_version || "unknown"} at ${data.venv_path}`
|
||||
: "Reading the environment…"}
|
||||
</p>
|
||||
{packages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing installed yet — node code has the standard library.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{packages.map((entry) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
|
||||
data-testid="module-row"
|
||||
>
|
||||
<span className="font-mono text-sm">{entry.name}</span>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{entry.version}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user