feat(phase7.3): workflow editor pipeline step nodes
- GET /api/workflows/pipeline-steps: returns all StepName enum values
with category (input|processing|rendering|output) + descriptions;
registered before /{workflow_id} to avoid path collision
- frontend/src/api/workflows.ts: getPipelineSteps(), PipelineStep
and PipelineStepsResponse interfaces
- WorkflowEditor: PipelineStepsPanel showing steps grouped by category
with collapsible accordion sections
- ConfigSidepanel: "Pipeline Step" select dropdown binds any node to a
StepName; selected step description shown below dropdown
- Active workflow indicator: green dot next to is_active=true entries
- Improved empty state with descriptive copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,3 +78,21 @@ export const deleteWorkflow = (id: string): Promise<void> =>
|
||||
|
||||
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
|
||||
api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
|
||||
|
||||
// ─── Pipeline Steps ───────────────────────────────────────────────────────────
|
||||
|
||||
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
|
||||
|
||||
export interface PipelineStep {
|
||||
name: string
|
||||
label: string
|
||||
category: StepCategory
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PipelineStepsResponse {
|
||||
steps: PipelineStep[]
|
||||
}
|
||||
|
||||
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
||||
api.get('/workflows/pipeline-steps').then(r => r.data)
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
getPipelineSteps,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowConfig,
|
||||
type WorkflowParams,
|
||||
type PipelineStep,
|
||||
type StepCategory,
|
||||
} from '../api/workflows'
|
||||
import {
|
||||
FileUp,
|
||||
@@ -232,14 +235,44 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[]
|
||||
function ConfigSidepanel({
|
||||
params,
|
||||
onChange,
|
||||
pipelineStep,
|
||||
onPipelineStepChange,
|
||||
pipelineSteps,
|
||||
}: {
|
||||
params: WorkflowParams
|
||||
onChange: (p: WorkflowParams) => void
|
||||
pipelineStep?: string
|
||||
onPipelineStepChange?: (step: string) => void
|
||||
pipelineSteps: PipelineStep[]
|
||||
}) {
|
||||
return (
|
||||
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto">
|
||||
<h3 className="font-semibold text-content">Node Configuration</h3>
|
||||
|
||||
{/* Pipeline Step binding */}
|
||||
{pipelineSteps.length > 0 && onPipelineStepChange && (
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">Pipeline Step</label>
|
||||
<select
|
||||
value={pipelineStep ?? ''}
|
||||
onChange={e => onPipelineStepChange(e.target.value)}
|
||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="">(not bound)</option>
|
||||
{pipelineSteps.map(s => (
|
||||
<option key={s.name} value={s.name}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pipelineStep && (
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{pipelineSteps.find(s => s.name === pipelineStep)?.description ?? ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Engine */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">Render Engine</label>
|
||||
@@ -341,6 +374,73 @@ function ConfigSidepanel({
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pipeline Steps Panel ─────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_LABELS: Record<StepCategory, string> = {
|
||||
input: 'Input',
|
||||
processing: 'Processing',
|
||||
rendering: 'Rendering',
|
||||
output: 'Output',
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<StepCategory, string> = {
|
||||
input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
}
|
||||
|
||||
function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
||||
const [expanded, setExpanded] = useState<StepCategory | null>(null)
|
||||
|
||||
const grouped = steps.reduce<Record<StepCategory, PipelineStep[]>>(
|
||||
(acc, step) => {
|
||||
acc[step.category] = [...(acc[step.category] ?? []), step]
|
||||
return acc
|
||||
},
|
||||
{ input: [], processing: [], rendering: [], output: [] },
|
||||
)
|
||||
|
||||
const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output']
|
||||
|
||||
return (
|
||||
<div className="border-t border-border-default pt-3 mt-3">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
|
||||
Pipeline Steps
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{categories.map(cat => (
|
||||
<div key={cat}>
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === cat ? null : cat)}
|
||||
className="w-full flex items-center justify-between text-xs text-content-secondary hover:text-content py-1"
|
||||
>
|
||||
<span className={`px-1.5 py-0.5 rounded-full font-medium ${CATEGORY_COLORS[cat]}`}>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
<span className="text-content-muted">{grouped[cat].length}</span>
|
||||
</button>
|
||||
{expanded === cat && (
|
||||
<div className="ml-2 mt-1 space-y-1">
|
||||
{grouped[cat].map(step => (
|
||||
<div
|
||||
key={step.name}
|
||||
className="text-xs bg-surface-hover rounded px-2 py-1.5"
|
||||
title={step.description}
|
||||
>
|
||||
<p className="font-mono text-content-secondary truncate">{step.name}</p>
|
||||
<p className="text-content-muted mt-0.5 line-clamp-2">{step.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node Palette ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_PALETTE = [
|
||||
@@ -449,19 +549,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
||||
|
||||
const { data: pipelineStepsData } = useQuery({
|
||||
queryKey: ['pipeline-steps'],
|
||||
queryFn: getPipelineSteps,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const pipelineSteps = pipelineStepsData?.steps ?? []
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges(eds => addEdge(connection, eds)),
|
||||
[setEdges],
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
if (node.type === 'renderNode' || node.type === 'renderFramesNode') {
|
||||
setSelectedNodeId(node.id)
|
||||
const nodeParams = (node.data as any).params as WorkflowParams | undefined
|
||||
if (nodeParams) setParams(nodeParams)
|
||||
} else {
|
||||
setSelectedNodeId(null)
|
||||
}
|
||||
setSelectedNodeId(node.id)
|
||||
const nodeParams = (node.data as any).params as WorkflowParams | undefined
|
||||
if (nodeParams) setParams(nodeParams)
|
||||
}, [])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -483,6 +586,20 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
[selectedNodeId, setNodes],
|
||||
)
|
||||
|
||||
const handlePipelineStepChange = useCallback(
|
||||
(stepName: string) => {
|
||||
setNodes(nds =>
|
||||
nds.map(n => {
|
||||
if (n.id === selectedNodeId) {
|
||||
return { ...n, data: { ...n.data, pipeline_step: stepName || undefined } }
|
||||
}
|
||||
return n
|
||||
}),
|
||||
)
|
||||
},
|
||||
[selectedNodeId, setNodes],
|
||||
)
|
||||
|
||||
// Drag-drop new nodes from palette
|
||||
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
@@ -579,8 +696,19 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && (
|
||||
<ConfigSidepanel params={params} onChange={handleParamsChange} />
|
||||
{selectedNode && (
|
||||
<ConfigSidepanel
|
||||
params={params}
|
||||
onChange={handleParamsChange}
|
||||
pipelineStep={(selectedNode.data as any).pipeline_step as string | undefined}
|
||||
onPipelineStepChange={handlePipelineStepChange}
|
||||
pipelineSteps={pipelineSteps}
|
||||
/>
|
||||
)}
|
||||
{!selectedNode && pipelineSteps.length > 0 && (
|
||||
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
|
||||
<PipelineStepsPanel steps={pipelineSteps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -686,16 +814,18 @@ export default function WorkflowEditor() {
|
||||
<p className="text-xs text-content-muted px-2 py-4 text-center">Loading…</p>
|
||||
)}
|
||||
{!isLoading && workflows.length === 0 && (
|
||||
<p className="text-xs text-content-muted px-2 py-4 text-center">
|
||||
No workflows yet.
|
||||
<br />
|
||||
<div className="px-2 py-4 text-center">
|
||||
<p className="text-xs text-content-secondary font-medium">No workflows configured.</p>
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
Workflows define the sequence of pipeline steps for rendering orders.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="mt-1 text-accent hover:underline"
|
||||
className="mt-2 text-xs text-accent hover:underline"
|
||||
>
|
||||
+ Create new
|
||||
+ New Workflow
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{workflows.map(wf => (
|
||||
<button
|
||||
@@ -708,7 +838,15 @@ export default function WorkflowEditor() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<p className="text-sm font-medium text-content truncate">{wf.name}</p>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{wf.is_active && (
|
||||
<span
|
||||
className="flex-shrink-0 w-2 h-2 rounded-full bg-green-500"
|
||||
title="Active"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-medium text-content truncate">{wf.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
@@ -768,16 +906,28 @@ export default function WorkflowEditor() {
|
||||
<div className="flex-1 flex items-center justify-center text-center">
|
||||
<div>
|
||||
<GitBranch size={48} className="mx-auto text-content-muted mb-4" />
|
||||
<p className="text-content-secondary font-medium">No workflow selected</p>
|
||||
<p className="text-sm text-content-muted mt-1">
|
||||
Select a workflow from the list or create a new one.
|
||||
</p>
|
||||
{workflows.length === 0 ? (
|
||||
<>
|
||||
<p className="text-content-secondary font-medium">No workflows configured.</p>
|
||||
<p className="text-sm text-content-muted mt-1 max-w-xs mx-auto">
|
||||
Workflows define the sequence of pipeline steps for rendering orders.
|
||||
Click "New Workflow" to create one.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-content-secondary font-medium">No workflow selected</p>
|
||||
<p className="text-sm text-content-muted mt-1">
|
||||
Select a workflow from the list or create a new one.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 mx-auto rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent-hover"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create workflow
|
||||
New Workflow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user