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
@@ -2,6 +2,7 @@
Endpoints:
GET /api/workflows/ — list all workflow definitions (admin/PM)
GET /api/workflows/pipeline-steps — list available pipeline step definitions
GET /api/workflows/{id} — get single definition (admin/PM)
POST /api/workflows/ — create definition (admin only)
PUT /api/workflows/{id} — update definition (admin only)
@@ -9,8 +10,10 @@ Endpoints:
GET /api/workflows/{id}/runs — list runs for a definition (admin/PM)
"""
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,10 +28,88 @@ from app.domains.rendering.schemas import (
WorkflowDefinitionOut,
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.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])
async def list_workflows(
_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[]> =>
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)
+163 -13
View File
@@ -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)
}
}, [])
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">
<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" />
{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>