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:
2026-03-08 20:24:17 +01:00
parent c99976cc85
commit 1409be171c
3 changed files with 276 additions and 27 deletions
@@ -1,16 +1,19 @@
"""Workflow definition CRUD API. """Workflow definition CRUD API.
Endpoints: Endpoints:
GET /api/workflows/ — list all workflow definitions (admin/PM) GET /api/workflows/ — list all workflow definitions (admin/PM)
GET /api/workflows/{id} — get single definition (admin/PM) GET /api/workflows/pipeline-steps — list available pipeline step definitions
POST /api/workflows/ — create definition (admin only) GET /api/workflows/{id} get single definition (admin/PM)
PUT /api/workflows/{id}update definition (admin only) POST /api/workflows/ create definition (admin only)
DELETE /api/workflows/{id} — delete definition (admin only) PUT /api/workflows/{id} — update definition (admin only)
GET /api/workflows/{id}/runs — list runs for a definition (admin/PM) DELETE /api/workflows/{id} — delete definition (admin only)
GET /api/workflows/{id}/runs — list runs for a definition (admin/PM)
""" """
import uuid import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,10 +28,88 @@ from app.domains.rendering.schemas import (
WorkflowDefinitionOut, WorkflowDefinitionOut,
WorkflowRunOut, WorkflowRunOut,
) )
from app.core.process_steps import StepName
# ── Pipeline-step metadata helpers ──────────────────────────────────────────
StepCategory = Literal["input", "processing", "rendering", "output"]
_STEP_CATEGORIES: dict[StepName, StepCategory] = {
StepName.RESOLVE_STEP_PATH: "input",
StepName.OCC_OBJECT_EXTRACT: "processing",
StepName.OCC_GLB_EXPORT: "processing",
StepName.GLB_BBOX: "processing",
StepName.MATERIAL_MAP_RESOLVE: "processing",
StepName.AUTO_POPULATE_MATERIALS: "processing",
StepName.BLENDER_RENDER: "rendering",
StepName.THREEJS_RENDER: "rendering",
StepName.THUMBNAIL_SAVE: "output",
StepName.ORDER_LINE_SETUP: "processing",
StepName.RESOLVE_TEMPLATE: "processing",
StepName.BLENDER_STILL: "rendering",
StepName.BLENDER_TURNTABLE: "rendering",
StepName.OUTPUT_SAVE: "output",
StepName.EXPORT_GLB_GEOMETRY: "output",
StepName.EXPORT_GLB_PRODUCTION: "output",
StepName.EXPORT_BLEND: "output",
StepName.STL_CACHE_GENERATE: "processing",
StepName.NOTIFY: "output",
}
_STEP_DESCRIPTIONS: dict[StepName, str] = {
StepName.RESOLVE_STEP_PATH: "Locate the STEP file on disk from the CadFile record",
StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC",
StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery",
StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing",
StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to SCHAEFFLER library materials via alias table",
StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names",
StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)",
StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser",
StepName.THUMBNAIL_SAVE: "Persist the rendered thumbnail bytes to the CadFile record",
StepName.ORDER_LINE_SETUP: "Validate and prepare an order line for rendering (check STEP path, output type)",
StepName.RESOLVE_TEMPLATE: "Look up the matching RenderTemplate for the order line's category + output type",
StepName.BLENDER_STILL: "Render a production still image (PNG) via Blender HTTP micro-service",
StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service",
StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record",
StepName.EXPORT_GLB_GEOMETRY: "Export a geometry-only GLB for the 3-D viewer (no materials)",
StepName.EXPORT_GLB_PRODUCTION: "Export a production GLB with full materials from the .blend template",
StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset",
StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file",
StepName.NOTIFY: "Emit a user notification via the audit-log notification channel",
}
class PipelineStepOut(BaseModel):
name: str
label: str
category: StepCategory
description: str
class PipelineStepsResponse(BaseModel):
steps: list[PipelineStepOut]
router = APIRouter(prefix="/api/workflows", tags=["workflows"]) router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@router.get("/pipeline-steps", response_model=PipelineStepsResponse)
async def get_pipeline_steps(
_user: User = Depends(require_admin_or_pm),
):
"""Return all available pipeline step definitions for the workflow editor."""
steps = [
PipelineStepOut(
name=step.value,
label=step.value.replace("_", " ").title(),
category=_STEP_CATEGORIES.get(step, "processing"),
description=_STEP_DESCRIPTIONS.get(step, ""),
)
for step in StepName
]
return PipelineStepsResponse(steps=steps)
@router.get("", response_model=list[WorkflowDefinitionOut]) @router.get("", response_model=list[WorkflowDefinitionOut])
async def list_workflows( async def list_workflows(
_user: User = Depends(require_admin_or_pm), _user: User = Depends(require_admin_or_pm),
+18
View File
@@ -78,3 +78,21 @@ export const deleteWorkflow = (id: string): Promise<void> =>
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> => export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
api.get(`/workflows/${workflowId}/runs`).then(r => r.data) 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)
+171 -21
View File
@@ -22,9 +22,12 @@ import {
createWorkflow, createWorkflow,
updateWorkflow, updateWorkflow,
deleteWorkflow, deleteWorkflow,
getPipelineSteps,
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowConfig, type WorkflowConfig,
type WorkflowParams, type WorkflowParams,
type PipelineStep,
type StepCategory,
} from '../api/workflows' } from '../api/workflows'
import { import {
FileUp, FileUp,
@@ -232,14 +235,44 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[]
function ConfigSidepanel({ function ConfigSidepanel({
params, params,
onChange, onChange,
pipelineStep,
onPipelineStepChange,
pipelineSteps,
}: { }: {
params: WorkflowParams params: WorkflowParams
onChange: (p: WorkflowParams) => void onChange: (p: WorkflowParams) => void
pipelineStep?: string
onPipelineStepChange?: (step: string) => void
pipelineSteps: PipelineStep[]
}) { }) {
return ( return (
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto"> <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> <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 */} {/* Render Engine */}
<div> <div>
<label className="text-sm text-content-secondary mb-2 block">Render Engine</label> <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 ────────────────────────────────────────────────────────────── // ─── Node Palette ──────────────────────────────────────────────────────────────
const NODE_PALETTE = [ const NODE_PALETTE = [
@@ -449,19 +549,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null) const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<any>(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( const onConnect = useCallback(
(connection: Connection) => setEdges(eds => addEdge(connection, eds)), (connection: Connection) => setEdges(eds => addEdge(connection, eds)),
[setEdges], [setEdges],
) )
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
if (node.type === 'renderNode' || node.type === 'renderFramesNode') { setSelectedNodeId(node.id)
setSelectedNodeId(node.id) const nodeParams = (node.data as any).params as WorkflowParams | undefined
const nodeParams = (node.data as any).params as WorkflowParams | undefined if (nodeParams) setParams(nodeParams)
if (nodeParams) setParams(nodeParams)
} else {
setSelectedNodeId(null)
}
}, []) }, [])
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
@@ -483,6 +586,20 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
[selectedNodeId, setNodes], [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 // Drag-drop new nodes from palette
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => { const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault() event.preventDefault()
@@ -579,8 +696,19 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
</ReactFlow> </ReactFlow>
</div> </div>
{selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && ( {selectedNode && (
<ConfigSidepanel params={params} onChange={handleParamsChange} /> <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>
</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> <p className="text-xs text-content-muted px-2 py-4 text-center">Loading</p>
)} )}
{!isLoading && workflows.length === 0 && ( {!isLoading && workflows.length === 0 && (
<p className="text-xs text-content-muted px-2 py-4 text-center"> <div className="px-2 py-4 text-center">
No workflows yet. <p className="text-xs text-content-secondary font-medium">No workflows configured.</p>
<br /> <p className="text-xs text-content-muted mt-1">
Workflows define the sequence of pipeline steps for rendering orders.
</p>
<button <button
onClick={() => setShowNewModal(true)} onClick={() => setShowNewModal(true)}
className="mt-1 text-accent hover:underline" className="mt-2 text-xs text-accent hover:underline"
> >
+ Create new + New Workflow
</button> </button>
</p> </div>
)} )}
{workflows.map(wf => ( {workflows.map(wf => (
<button <button
@@ -708,7 +838,15 @@ export default function WorkflowEditor() {
}`} }`}
> >
<div className="flex items-start justify-between gap-1"> <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 <button
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
@@ -768,16 +906,28 @@ export default function WorkflowEditor() {
<div className="flex-1 flex items-center justify-center text-center"> <div className="flex-1 flex items-center justify-center text-center">
<div> <div>
<GitBranch size={48} className="mx-auto text-content-muted mb-4" /> <GitBranch size={48} className="mx-auto text-content-muted mb-4" />
<p className="text-content-secondary font-medium">No workflow selected</p> {workflows.length === 0 ? (
<p className="text-sm text-content-muted mt-1"> <>
Select a workflow from the list or create a new one. <p className="text-content-secondary font-medium">No workflows configured.</p>
</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 <button
onClick={() => setShowNewModal(true)} 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" 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} /> <Plus size={16} />
Create workflow New Workflow
</button> </button>
</div> </div>
</div> </div>