@@ -0,0 +1,863 @@
{% extends "base.html" %}
{% block title %}Processing Pipelines – DocuElevate{% endblock %}
{% block content %}
< div class = "container mx-auto px-4 py-8" x-data = "pipelinesApp()" x-init = "init()" >
<!-- ── Header ─────────────────────────────────────────────────────────────── -->
< div class = "mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" >
< div >
< h1 class = "text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2" >
< i class = "fas fa-project-diagram text-blue-500" aria-hidden = "true" > < / i >
Processing Pipelines
< / h1 >
< p class = "text-gray-500 dark:text-gray-400 text-sm mt-1" >
Define and manage custom document processing workflows.
System pipelines (created by admins) are shown with a
< span class = "text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1" > System< / span >
badge and are visible to all users.
< / p >
< / div >
< button
type = "button"
@ click = "openCreatePipelineModal()"
class = "inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
style = "min-height:44px;"
>
< i class = "fas fa-plus mr-2" aria-hidden = "true" > < / i > New Pipeline
< / button >
< / div >
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
<!-- Two containers with static aria - live so screen readers register them on page load -->
< div aria-live = "polite" aria-atomic = "true" >
< div x-show = "alert.show && alert.type !== 'error'" x-transition class = "mb-4" role = "status" >
< div class = "bg-green-50 border-green-400 text-green-800 border-l-4 p-4 rounded dark:bg-opacity-10" >
< p class = "font-semibold" x-text = "alert.title" > < / p >
< p class = "text-sm" x-text = "alert.message" > < / p >
< / div >
< / div >
< / div >
< div aria-live = "assertive" aria-atomic = "true" >
< div x-show = "alert.show && alert.type === 'error'" x-transition class = "mb-4" role = "alert" >
< div class = "bg-red-50 border-red-400 text-red-800 border-l-4 p-4 rounded dark:bg-opacity-10" >
< p class = "font-semibold" x-text = "alert.title" > < / p >
< p class = "text-sm" x-text = "alert.message" > < / p >
< / div >
< / div >
< / div >
<!-- ── Loading ────────────────────────────────────────────────────────────── -->
< template x-if = "loading" >
< div class = "text-center py-12 text-gray-400" >
< i class = "fas fa-spinner fa-spin text-3xl mb-3" aria-hidden = "true" > < / i >
< p > Loading pipelines…< / p >
< / div >
< / template >
<!-- ── Empty state ────────────────────────────────────────────────────────── -->
< template x-if = "!loading && pipelines.length === 0" >
< div class = "text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow" >
< i class = "fas fa-project-diagram text-5xl text-gray-300 mb-4" aria-hidden = "true" > < / i >
< h2 class = "text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2" > No pipelines yet< / h2 >
< p class = "text-gray-500 dark:text-gray-400 mb-6" > Create your first pipeline to define custom document processing workflows.< / p >
< button
type = "button"
@ click = "openCreatePipelineModal()"
class = "inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md"
style = "min-height:44px;"
>
< i class = "fas fa-plus mr-2" aria-hidden = "true" > < / i > Create Pipeline
< / button >
< / div >
< / template >
<!-- ── Pipeline cards ─────────────────────────────────────────────────────── -->
< template x-if = "!loading && pipelines.length > 0" >
< div class = "space-y-4" >
< template x-for = "pipeline in pipelines" :key = "pipeline.id" >
< div class = "bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden" >
<!-- Card header -->
< div class = "px-5 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-gray-100 dark:border-gray-700" >
< div class = "flex items-center gap-2 flex-wrap" >
< h2 class = "text-lg font-semibold text-gray-900 dark:text-white" x-text = "pipeline.name" > < / h2 >
<!-- Badges -->
< template x-if = "pipeline.owner_id === null" >
< span class = "text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5" > System< / span >
< / template >
< template x-if = "pipeline.is_default" >
< span class = "text-xs font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1.5 py-0.5" > Default< / span >
< / template >
< template x-if = "!pipeline.is_active" >
< span class = "text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded px-1.5 py-0.5" > Inactive< / span >
< / template >
< / div >
<!-- Actions -->
< div class = "flex items-center gap-2 flex-shrink-0" >
< button
type = "button"
@ click = "toggleSteps(pipeline)"
class = "inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
style = "min-height:36px;"
:aria-expanded = "pipeline._expanded"
:aria-label = "`${pipeline._expanded ? 'Collapse' : 'Expand'} steps for ${pipeline.name}`"
>
< i class = "fas fa-list-ul mr-1" aria-hidden = "true" > < / i >
< span x-text = "(pipeline.steps || []).length + ' step' + ((pipeline.steps || []).length !== 1 ? 's' : '')" > < / span >
< i :class = "pipeline._expanded ? 'fa-chevron-up' : 'fa-chevron-down'" class = "fas ml-1 text-xs" aria-hidden = "true" > < / i >
< / button >
< button
type = "button"
@ click = "openEditPipelineModal(pipeline)"
class = "inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md"
style = "min-height:36px;"
:aria-label = "`Edit pipeline ${pipeline.name}`"
>
< i class = "fas fa-pencil-alt mr-1" aria-hidden = "true" > < / i > Edit
< / button >
< button
type = "button"
@ click = "confirmDeletePipeline(pipeline)"
class = "inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-md"
style = "min-height:36px;"
:aria-label = "`Delete pipeline ${pipeline.name}`"
>
< i class = "fas fa-trash mr-1" aria-hidden = "true" > < / i > Delete
< / button >
< / div >
< / div >
<!-- Description -->
< template x-if = "pipeline.description" >
< p class = "px-5 pt-3 text-sm text-gray-500 dark:text-gray-400" x-text = "pipeline.description" > < / p >
< / template >
<!-- Steps (collapsible) -->
< div x-show = "pipeline._expanded" x-transition class = "px-5 py-4" >
<!-- Step list -->
< template x-if = "(pipeline.steps || []).length === 0" >
< p class = "text-sm text-gray-400 italic mb-3" > No steps defined. Add a step to start building your pipeline.< / p >
< / template >
< template x-if = "(pipeline.steps || []).length > 0" >
< ol class = "space-y-2 mb-4" role = "list" :aria-label = "`Steps for ${pipeline.name}`" >
< template x-for = "(step, idx) in (pipeline.steps || [])" :key = "step.id" >
< li class = "flex items-center justify-between bg-gray-50 dark:bg-gray-700 rounded-md px-4 py-2.5 gap-3" >
< div class = "flex items-center gap-3 min-w-0" >
< span class = "flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-xs font-bold flex items-center justify-center" x-text = "idx + 1" aria-hidden = "true" > < / span >
< div class = "min-w-0" >
< p class = "text-sm font-medium text-gray-800 dark:text-gray-200 truncate" x-text = "step.label || stepTypeLabel(step.step_type)" > < / p >
< p class = "text-xs text-gray-400 truncate" x-text = "step.step_type" > < / p >
< / div >
< template x-if = "!step.enabled" >
< span class = "text-xs text-gray-400 bg-gray-200 rounded px-1.5 py-0.5 flex-shrink-0" > Disabled< / span >
< / template >
< / div >
< div class = "flex items-center gap-1.5 flex-shrink-0" >
< button
type = "button"
@ click = "openEditStepModal(pipeline, step)"
class = "p-1.5 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900 rounded"
style = "min-height:36px;min-width:36px;"
:aria-label = "`Edit step ${step.label || step.step_type}`"
> < i class = "fas fa-pencil-alt text-xs" aria-hidden = "true" > < / i > < / button >
< button
type = "button"
@ click = "moveStep(pipeline, idx, -1)"
:disabled = "idx === 0"
class = "p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600 rounded disabled:opacity-30"
style = "min-height:36px;min-width:36px;"
:aria-label = "`Move step up: ${step.label || step.step_type}`"
> < i class = "fas fa-arrow-up text-xs" aria-hidden = "true" > < / i > < / button >
< button
type = "button"
@ click = "moveStep(pipeline, idx, 1)"
:disabled = "idx === (pipeline.steps || []).length - 1"
class = "p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600 rounded disabled:opacity-30"
style = "min-height:36px;min-width:36px;"
:aria-label = "`Move step down: ${step.label || step.step_type}`"
> < i class = "fas fa-arrow-down text-xs" aria-hidden = "true" > < / i > < / button >
< button
type = "button"
@ click = "confirmDeleteStep(pipeline, step)"
class = "p-1.5 text-red-400 hover:bg-red-50 dark:hover:bg-red-900 rounded"
style = "min-height:36px;min-width:36px;"
:aria-label = "`Delete step ${step.label || step.step_type}`"
> < i class = "fas fa-times text-xs" aria-hidden = "true" > < / i > < / button >
< / div >
< / li >
< / template >
< / ol >
< / template >
<!-- Add step button -->
< button
type = "button"
@ click = "openAddStepModal(pipeline)"
class = "inline-flex items-center px-3 py-1.5 text-xs font-medium text-green-600 bg-green-50 hover:bg-green-100 rounded-md border border-green-200"
style = "min-height:36px;"
:aria-label = "`Add step to ${pipeline.name}`"
>
< i class = "fas fa-plus mr-1" aria-hidden = "true" > < / i > Add Step
< / button >
< / div >
< / div >
< / template >
< / div >
< / template >
<!-- ═══════════════════════════════════════════════════════════════════════ -->
<!-- Modal: Create / Edit Pipeline -->
<!-- ═══════════════════════════════════════════════════════════════════════ -->
< div
x-show = "pipelineModal.open"
x-transition:enter = "transition ease-out duration-200"
x-transition:enter-start = "opacity-0"
x-transition:enter-end = "opacity-100"
x-transition:leave = "transition ease-in duration-150"
x-transition:leave-start = "opacity-100"
x-transition:leave-end = "opacity-0"
class = "fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role = "dialog"
aria-modal = "true"
:aria-labelledby = "pipelineModal.editMode ? 'editPipelineTitle' : 'createPipelineTitle'"
@ keydown . escape . window = "closePipelineModal()"
>
< div class = "bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6" @ click . stop >
< h2
:id = "pipelineModal.editMode ? 'editPipelineTitle' : 'createPipelineTitle'"
class = "text-xl font-semibold text-gray-900 dark:text-white mb-5"
x-text = "pipelineModal.editMode ? 'Edit Pipeline' : 'New Pipeline'"
> < / h2 >
< form @ submit . prevent = "savePipeline()" novalidate >
< div class = "space-y-4" >
<!-- Name -->
< div >
< label for = "pipelineName" class = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" >
Name < span aria-hidden = "true" class = "text-red-500" > *< / span >
< / label >
< input
id = "pipelineName"
type = "text"
x-model = "pipelineModal.form.name"
maxlength = "255"
required
class = "w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
placeholder = "My pipeline"
autocomplete = "off"
/ >
< / div >
<!-- Description -->
< div >
< label for = "pipelineDesc" class = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" > Description< / label >
< textarea
id = "pipelineDesc"
x-model = "pipelineModal.form.description"
rows = "2"
maxlength = "4096"
class = "w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white resize-none"
placeholder = "Optional description"
> < / textarea >
< / div >
<!-- Flags -->
< div class = "flex flex-col gap-2" >
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
x-model = "pipelineModal.form.is_default"
class = "rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/ >
< span class = "text-sm text-gray-700 dark:text-gray-300" > Set as my default pipeline< / span >
< / label >
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
x-model = "pipelineModal.form.is_active"
class = "rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/ >
< span class = "text-sm text-gray-700 dark:text-gray-300" > Active< / span >
< / label >
< / div >
<!-- System pipeline option (admin only, create only) -->
< template x-if = "isAdmin && !pipelineModal.editMode" >
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
x-model = "pipelineModal.form.system"
class = "rounded border-gray-300 text-purple-600 focus:ring-purple-400"
/ >
< span class = "text-sm text-gray-700 dark:text-gray-300" > System pipeline (visible to all users)< / span >
< / label >
< / template >
<!-- Error message -->
< p x-show = "pipelineModal.error" class = "text-sm text-red-600" x-text = "pipelineModal.error" role = "alert" > < / p >
< / div >
< div class = "flex justify-end gap-3 mt-6" >
< button
type = "button"
@ click = "closePipelineModal()"
class = "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style = "min-height:44px;"
> Cancel< / button >
< button
type = "submit"
:disabled = "pipelineModal.saving"
class = "px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60 rounded-md"
style = "min-height:44px;"
>
< template x-if = "pipelineModal.saving" >
< i class = "fas fa-spinner fa-spin mr-1" aria-hidden = "true" > < / i >
< / template >
< span x-text = "pipelineModal.editMode ? 'Save Changes' : 'Create'" > < / span >
< / button >
< / div >
< / form >
< / div >
< / div >
<!-- ═══════════════════════════════════════════════════════════════════════ -->
<!-- Modal: Add / Edit Step -->
<!-- ═══════════════════════════════════════════════════════════════════════ -->
< div
x-show = "stepModal.open"
x-transition:enter = "transition ease-out duration-200"
x-transition:enter-start = "opacity-0"
x-transition:enter-end = "opacity-100"
x-transition:leave = "transition ease-in duration-150"
x-transition:leave-start = "opacity-100"
x-transition:leave-end = "opacity-0"
class = "fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role = "dialog"
aria-modal = "true"
:aria-labelledby = "stepModal.editMode ? 'editStepTitle' : 'addStepTitle'"
@ keydown . escape . window = "closeStepModal()"
>
< div class = "bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 p-6" @ click . stop >
< h2
:id = "stepModal.editMode ? 'editStepTitle' : 'addStepTitle'"
class = "text-xl font-semibold text-gray-900 dark:text-white mb-5"
x-text = "stepModal.editMode ? 'Edit Step' : 'Add Step'"
> < / h2 >
< form @ submit . prevent = "saveStep()" novalidate >
< div class = "space-y-4" >
<!-- Step type -->
< div >
< label for = "stepType" class = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" >
Step Type < span aria-hidden = "true" class = "text-red-500" > *< / span >
< / label >
< select
id = "stepType"
x-model = "stepModal.form.step_type"
:disabled = "stepModal.editMode"
class = "w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
>
< option value = "" > — select —< / option >
< template x-for = "[key, meta] in Object.entries(stepTypes)" :key = "key" >
< option :value = "key" x-text = "meta.label" > < / option >
< / template >
< / select >
< template x-if = "stepModal.form.step_type && stepTypes[stepModal.form.step_type]" >
< p class = "mt-1 text-xs text-gray-400" x-text = "stepTypes[stepModal.form.step_type].description" > < / p >
< / template >
< / div >
<!-- Custom label -->
< div >
< label for = "stepLabel" class = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" >
Custom Label < span class = "text-gray-400 font-normal" > (optional)< / span >
< / label >
< input
id = "stepLabel"
type = "text"
x-model = "stepModal.form.label"
maxlength = "255"
class = "w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
placeholder = "Override the default step name"
autocomplete = "off"
/ >
< / div >
<!-- force_cloud_ocr (only shown for ocr step) -->
< template x-if = "stepModal.form.step_type === 'ocr'" >
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
x-model = "stepModal.form.config.force_cloud_ocr"
class = "rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/ >
< span class = "text-sm text-gray-700 dark:text-gray-300" > Force cloud OCR (skip local text extraction)< / span >
< / label >
< / template >
<!-- Enabled -->
< label class = "inline-flex items-center gap-2 cursor-pointer" >
< input
type = "checkbox"
x-model = "stepModal.form.enabled"
class = "rounded border-gray-300 text-blue-600 focus:ring-blue-400"
/ >
< span class = "text-sm text-gray-700 dark:text-gray-300" > Enabled< / span >
< / label >
<!-- Error -->
< p x-show = "stepModal.error" class = "text-sm text-red-600" x-text = "stepModal.error" role = "alert" > < / p >
< / div >
< div class = "flex justify-end gap-3 mt-6" >
< button
type = "button"
@ click = "closeStepModal()"
class = "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style = "min-height:44px;"
> Cancel< / button >
< button
type = "submit"
:disabled = "stepModal.saving"
class = "px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60 rounded-md"
style = "min-height:44px;"
>
< template x-if = "stepModal.saving" >
< i class = "fas fa-spinner fa-spin mr-1" aria-hidden = "true" > < / i >
< / template >
< span x-text = "stepModal.editMode ? 'Save Changes' : 'Add Step'" > < / span >
< / button >
< / div >
< / form >
< / div >
< / div >
<!-- ═══════════════════════════════════════════════════════════════════════ -->
<!-- Confirm delete dialog -->
<!-- ═══════════════════════════════════════════════════════════════════════ -->
< div
x-show = "confirmModal.open"
x-transition:enter = "transition ease-out duration-150"
x-transition:enter-start = "opacity-0"
x-transition:enter-end = "opacity-100"
x-transition:leave = "transition ease-in duration-100"
x-transition:leave-start = "opacity-100"
x-transition:leave-end = "opacity-0"
class = "fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role = "alertdialog"
aria-modal = "true"
aria-labelledby = "confirmTitle"
@ keydown . escape . window = "confirmModal.open = false"
>
< div class = "bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm mx-4 p-6" @ click . stop >
< h2 id = "confirmTitle" class = "text-lg font-semibold text-gray-900 dark:text-white mb-2" x-text = "confirmModal.title" > < / h2 >
< p class = "text-sm text-gray-600 dark:text-gray-400 mb-6" x-text = "confirmModal.message" > < / p >
< div class = "flex justify-end gap-3" >
< button
type = "button"
@ click = "confirmModal.open = false"
class = "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50"
style = "min-height:44px;"
> Cancel< / button >
< button
type = "button"
@ click = "confirmModal.action(); confirmModal.open = false"
class = "px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md"
style = "min-height:44px;"
> Delete< / button >
< / div >
< / div >
< / div >
< / div >
{% endblock %}
{% block head_extra %}
< script >
function pipelinesApp ( ) {
return {
loading : true ,
pipelines : [ ] ,
stepTypes : { } ,
isAdmin : false ,
alert : { show : false , type : 'success' , title : '' , message : '' } ,
pipelineModal : {
open : false ,
editMode : false ,
pipelineId : null ,
saving : false ,
error : '' ,
form : { name : '' , description : '' , is _default : false , is _active : true , system : false } ,
} ,
stepModal : {
open : false ,
editMode : false ,
pipeline : null ,
stepId : null ,
saving : false ,
error : '' ,
form : { step _type : '' , label : '' , config : { } , enabled : true } ,
} ,
confirmModal : {
open : false ,
title : '' ,
message : '' ,
action : ( ) => { } ,
} ,
// ── Initialisation ────────────────────────────────────────────────────
async init ( ) {
await Promise . all ( [ this . fetchStepTypes ( ) , this . fetchCurrentUser ( ) ] ) ;
await this . fetchPipelines ( ) ;
} ,
async fetchCurrentUser ( ) {
try {
const r = await fetch ( '/api/auth/whoami' ) ;
if ( r . ok ) {
const u = await r . json ( ) ;
this . isAdmin = ! ! u . is _admin ;
}
} catch ( _ ) { }
} ,
async fetchStepTypes ( ) {
try {
const r = await fetch ( '/api/pipelines/step-types' ) ;
if ( r . ok ) this . stepTypes = await r . json ( ) ;
} catch ( _ ) { }
} ,
async fetchPipelines ( ) {
this . loading = true ;
try {
const r = await fetch ( '/api/pipelines' ) ;
if ( ! r . ok ) throw new Error ( await r . text ( ) ) ;
const list = await r . json ( ) ;
// Fetch steps for each pipeline
const detailed = await Promise . all ( list . map ( p => this . fetchPipeline ( p . id ) ) ) ;
this . pipelines = detailed . map ( p => ( { ... p , _expanded : false } ) ) ;
} catch ( err ) {
this . showAlert ( 'error' , 'Failed to load pipelines' , err . message || String ( err ) ) ;
} finally {
this . loading = false ;
}
} ,
async fetchPipeline ( id ) {
const r = await fetch ( ` /api/pipelines/ ${ id } ` ) ;
if ( ! r . ok ) throw new Error ( ` Pipeline ${ id } not found ` ) ;
return r . json ( ) ;
} ,
// ── Helpers ───────────────────────────────────────────────────────────
stepTypeLabel ( key ) {
return this . stepTypes [ key ] ? this . stepTypes [ key ] . label : key ;
} ,
showAlert ( type , title , message ) {
this . alert = { show : true , type , title , message } ;
setTimeout ( ( ) => { this . alert . show = false ; } , 6000 ) ;
} ,
toggleSteps ( pipeline ) {
pipeline . _expanded = ! pipeline . _expanded ;
} ,
// ── Pipeline Modal ────────────────────────────────────────────────────
openCreatePipelineModal ( ) {
this . pipelineModal = {
open : true ,
editMode : false ,
pipelineId : null ,
saving : false ,
error : '' ,
form : { name : '' , description : '' , is _default : false , is _active : true , system : false } ,
} ;
} ,
openEditPipelineModal ( pipeline ) {
this . pipelineModal = {
open : true ,
editMode : true ,
pipelineId : pipeline . id ,
saving : false ,
error : '' ,
form : {
name : pipeline . name ,
description : pipeline . description || '' ,
is _default : pipeline . is _default ,
is _active : pipeline . is _active ,
system : false ,
} ,
} ;
} ,
closePipelineModal ( ) {
this . pipelineModal . open = false ;
} ,
async savePipeline ( ) {
const fm = this . pipelineModal . form ;
if ( ! fm . name . trim ( ) ) {
this . pipelineModal . error = 'Pipeline name is required.' ;
return ;
}
this . pipelineModal . error = '' ;
this . pipelineModal . saving = true ;
try {
let r ;
if ( this . pipelineModal . editMode ) {
r = await fetch ( ` /api/pipelines/ ${ this . pipelineModal . pipelineId } ` , {
method : 'PUT' ,
headers : this . _jsonHeaders ( ) ,
body : JSON . stringify ( {
name : fm . name . trim ( ) ,
description : fm . description || null ,
is _default : fm . is _default ,
is _active : fm . is _active ,
} ) ,
} ) ;
} else {
const url = fm . system ? '/api/pipelines/admin/system' : '/api/pipelines' ;
r = await fetch ( url , {
method : 'POST' ,
headers : this . _jsonHeaders ( ) ,
body : JSON . stringify ( {
name : fm . name . trim ( ) ,
description : fm . description || null ,
is _default : fm . is _default ,
is _active : fm . is _active ,
} ) ,
} ) ;
}
if ( ! r . ok ) {
const e = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( e . detail || ` HTTP ${ r . status } ` ) ;
}
const saved = await r . json ( ) ;
if ( this . pipelineModal . editMode ) {
const idx = this . pipelines . findIndex ( p => p . id === saved . id ) ;
if ( idx !== - 1 ) {
const expanded = this . pipelines [ idx ] . _expanded ;
this . pipelines [ idx ] = { ... saved , steps : this . pipelines [ idx ] . steps || [ ] , _expanded : expanded } ;
}
} else {
this . pipelines . push ( { ... saved , steps : [ ] , _expanded : false } ) ;
}
this . closePipelineModal ( ) ;
this . showAlert ( 'success' , 'Pipeline saved' , ` " ${ saved . name } " has been ${ this . pipelineModal . editMode ? 'updated' : 'created' } . ` ) ;
} catch ( err ) {
this . pipelineModal . error = err . message || 'Unknown error' ;
} finally {
this . pipelineModal . saving = false ;
}
} ,
confirmDeletePipeline ( pipeline ) {
this . confirmModal = {
open : true ,
title : 'Delete pipeline?' ,
message : ` Are you sure you want to permanently delete " ${ pipeline . name } " and all its steps? This cannot be undone. ` ,
action : ( ) => this . deletePipeline ( pipeline ) ,
} ;
} ,
async deletePipeline ( pipeline ) {
try {
const r = await fetch ( ` /api/pipelines/ ${ pipeline . id } ` , { method : 'DELETE' , headers : this . _csrfHeaders ( ) } ) ;
if ( ! r . ok ) {
const e = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( e . detail || ` HTTP ${ r . status } ` ) ;
}
this . pipelines = this . pipelines . filter ( p => p . id !== pipeline . id ) ;
this . showAlert ( 'success' , 'Pipeline deleted' , ` " ${ pipeline . name } " has been deleted. ` ) ;
} catch ( err ) {
this . showAlert ( 'error' , 'Delete failed' , err . message || String ( err ) ) ;
}
} ,
// ── Step Modal ────────────────────────────────────────────────────────
openAddStepModal ( pipeline ) {
this . stepModal = {
open : true ,
editMode : false ,
pipeline ,
stepId : null ,
saving : false ,
error : '' ,
form : { step _type : '' , label : '' , config : { } , enabled : true } ,
} ;
} ,
openEditStepModal ( pipeline , step ) {
this . stepModal = {
open : true ,
editMode : true ,
pipeline ,
stepId : step . id ,
saving : false ,
error : '' ,
form : {
step _type : step . step _type ,
label : step . label || '' ,
config : { ... ( step . config || { } ) } ,
enabled : step . enabled ,
} ,
} ;
} ,
closeStepModal ( ) {
this . stepModal . open = false ;
} ,
async saveStep ( ) {
const fm = this . stepModal . form ;
if ( ! fm . step _type ) {
this . stepModal . error = 'Step type is required.' ;
return ;
}
this . stepModal . error = '' ;
this . stepModal . saving = true ;
const pid = this . stepModal . pipeline . id ;
try {
let r ;
if ( this . stepModal . editMode ) {
r = await fetch ( ` /api/pipelines/ ${ pid } /steps/ ${ this . stepModal . stepId } ` , {
method : 'PUT' ,
headers : this . _jsonHeaders ( ) ,
body : JSON . stringify ( {
step _type : fm . step _type ,
label : fm . label || null ,
config : fm . config || { } ,
enabled : fm . enabled ,
} ) ,
} ) ;
} else {
r = await fetch ( ` /api/pipelines/ ${ pid } /steps ` , {
method : 'POST' ,
headers : this . _jsonHeaders ( ) ,
body : JSON . stringify ( {
step _type : fm . step _type ,
label : fm . label || null ,
config : fm . config || { } ,
enabled : fm . enabled ,
} ) ,
} ) ;
}
if ( ! r . ok ) {
const e = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( e . detail || ` HTTP ${ r . status } ` ) ;
}
const savedStep = await r . json ( ) ;
// Refresh the pipeline's steps
const updated = await this . fetchPipeline ( pid ) ;
const pIdx = this . pipelines . findIndex ( p => p . id === pid ) ;
if ( pIdx !== - 1 ) {
const expanded = this . pipelines [ pIdx ] . _expanded ;
this . pipelines [ pIdx ] = { ... updated , _expanded : expanded } ;
}
this . closeStepModal ( ) ;
this . showAlert ( 'success' , 'Step saved' , ` Step " ${ savedStep . label || savedStep . step _type } " has been ${ this . stepModal . editMode ? 'updated' : 'added' } . ` ) ;
} catch ( err ) {
this . stepModal . error = err . message || 'Unknown error' ;
} finally {
this . stepModal . saving = false ;
}
} ,
async moveStep ( pipeline , idx , direction ) {
const steps = pipeline . steps || [ ] ;
const newIdx = idx + direction ;
if ( newIdx < 0 || newIdx >= steps . length ) return ;
// Build the new order
const reordered = [ ... steps ] ;
[ reordered [ idx ] , reordered [ newIdx ] ] = [ reordered [ newIdx ] , reordered [ idx ] ] ;
const ids = reordered . map ( s => s . id ) ;
try {
const r = await fetch ( ` /api/pipelines/ ${ pipeline . id } /steps/reorder ` , {
method : 'PUT' ,
headers : this . _jsonHeaders ( ) ,
body : JSON . stringify ( ids ) ,
} ) ;
if ( ! r . ok ) {
const e = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( e . detail || ` HTTP ${ r . status } ` ) ;
}
const updated = await this . fetchPipeline ( pipeline . id ) ;
const pIdx = this . pipelines . findIndex ( p => p . id === pipeline . id ) ;
if ( pIdx !== - 1 ) {
this . pipelines [ pIdx ] = { ... updated , _expanded : true } ;
}
} catch ( err ) {
this . showAlert ( 'error' , 'Reorder failed' , err . message || String ( err ) ) ;
}
} ,
confirmDeleteStep ( pipeline , step ) {
this . confirmModal = {
open : true ,
title : 'Delete step?' ,
message : ` Remove " ${ step . label || step . step _type } " from pipeline " ${ pipeline . name } "? ` ,
action : ( ) => this . deleteStep ( pipeline , step ) ,
} ;
} ,
async deleteStep ( pipeline , step ) {
try {
const r = await fetch ( ` /api/pipelines/ ${ pipeline . id } /steps/ ${ step . id } ` , {
method : 'DELETE' ,
headers : this . _csrfHeaders ( ) ,
} ) ;
if ( ! r . ok ) {
const e = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
throw new Error ( e . detail || ` HTTP ${ r . status } ` ) ;
}
const updated = await this . fetchPipeline ( pipeline . id ) ;
const pIdx = this . pipelines . findIndex ( p => p . id === pipeline . id ) ;
if ( pIdx !== - 1 ) {
this . pipelines [ pIdx ] = { ... updated , _expanded : true } ;
}
this . showAlert ( 'success' , 'Step deleted' , ` " ${ step . label || step . step _type } " removed. ` ) ;
} catch ( err ) {
this . showAlert ( 'error' , 'Delete failed' , err . message || String ( err ) ) ;
}
} ,
// ── Fetch helpers ─────────────────────────────────────────────────────
_csrfToken ( ) {
return document . querySelector ( 'meta[name="csrf-token"]' ) ? . content || '' ;
} ,
_csrfHeaders ( ) {
return { 'X-CSRF-Token' : this . _csrfToken ( ) } ;
} ,
_jsonHeaders ( ) {
return { 'Content-Type' : 'application/json' , 'X-CSRF-Token' : this . _csrfToken ( ) } ;
} ,
} ;
}
< / script >
{% endblock %}