Files
gh-christianlouis-docuelevate/frontend/templates/pipelines.html
T
copilot-swe-agent[bot] 1203a4b75f refactor(pipelines): address code review - shared get_current_user_id, aria-live, deduplicate user ID logic
- Extract _get_user_id into shared auth.get_current_user_id() used by both
  pipelines API and the assign-pipeline endpoint in files API
- Fix aria-live attribute: use two separate static containers (polite/assertive)
  instead of dynamic Alpine.js binding for correct screen reader announcements
- Fix migration comment to accurately describe batch-mode FK creation
- Remove redundant tags parameter from reorder endpoint decorator
- Rename _make_file test helper to _make_test_file_record for clarity
- Update docs/UserGuide.md and docs/API.md with full Pipelines reference

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-07 10:39:19 +00:00

864 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}