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:
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user