Files
copilot-swe-agent[bot] b483ed16fc fix(i18n): register _ in all Jinja2 template instances, sync translation files, fix pipelines.html
- Register _() translation function in app/auth.py, app/api/local_auth.py, and
  app/api/billing.py template env globals to fix jinja2.exceptions.UndefinedError
- Update test_settings_template_has_db_wizard_link to assert i18n key instead of
  literal "DB Wizard" (template now uses {{ _("settings.db_wizard_btn") }})
- Add 418 missing en.json keys to all 30 non-English translation files as English
  fallbacks to fix test_all_languages_have_same_keys
- Fix orphan </template> tag in pipelines.html by adding missing
  <template x-if="pipelineModal.saving"> opening tag (fixes djlint H025)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 12:46:11 +00:00

911 lines
39 KiB
HTML
Raw Permalink 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 %}{{ _("pipelines.title") }} 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>
{{ _("pipelines.page_title") }}
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
{{ _("pipelines.subtitle_intro") }}
{{ _("pipelines.subtitle_system_pre") }}
<span class="text-xs font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1">{{ _("pipelines.system_label") }}</span>
{{ _("pipelines.subtitle_system_post") }}
</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> {{ _("pipelines.new_pipeline_btn") }}
</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>{{ _("pipelines.loading") }}</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">{{ _("pipelines.empty_state") }}</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">{{ _("pipelines.empty_state_hint") }}</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> {{ _("pipelines.create") }}
</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">{{ _("pipelines.system_label") }}</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">{{ _("pipelines.default_label") }}</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">{{ _("pipelines.inactive_label") }}</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> {{ _("common.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> {{ _("common.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">{{ _("pipelines.no_steps") }}</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">{{ _("pipelines.disabled_label") }}</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> {{ _("pipelines.add_step_btn") }}
</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">
{{ _("common.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="{{ _('pipelines.name_placeholder') }}"
autocomplete="off"
/>
</div>
<!-- Description -->
<div>
<label for="pipelineDesc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _("common.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="{{ _('pipelines.description_placeholder') }}"
></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">{{ _("pipelines.set_default") }}</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">{{ _("pipelines.active_label") }}</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">{{ _("pipelines.system_pipeline_label") }}</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;"
>{{ _("common.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">
{{ _("pipelines.step_type_label") }} <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="">{{ _("common.select_placeholder") }}</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">
{{ _("pipelines.custom_label_label") }} <span class="text-gray-400 font-normal">{{ _("pipelines.optional_suffix") }}</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="{{ _('pipelines.step_label_placeholder') }}"
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">{{ _("pipelines.force_cloud_ocr_label") }}</span>
</label>
</template>
<!-- ocr_language (only shown for ocr step) -->
<template x-if="stepModal.form.step_type === 'ocr'">
<div>
<label for="ocrLanguage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _("pipelines.ocr_language_label") }}
</label>
<select
id="ocrLanguage"
x-model="stepModal.form.config.ocr_language"
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="auto">{{ _("pipelines.ocr_auto") }}</option>
<option value="ara">Arabic</option>
<option value="chi_sim">Chinese (Simplified)</option>
<option value="chi_tra">Chinese (Traditional)</option>
<option value="ces">Czech</option>
<option value="dan">Danish</option>
<option value="nld">Dutch</option>
<option value="eng">English</option>
<option value="fin">Finnish</option>
<option value="fra">French</option>
<option value="deu">German</option>
<option value="ell">Greek</option>
<option value="heb">Hebrew</option>
<option value="hin">Hindi</option>
<option value="hun">Hungarian</option>
<option value="ita">Italian</option>
<option value="jpn">Japanese</option>
<option value="kor">Korean</option>
<option value="nor">Norwegian</option>
<option value="pol">Polish</option>
<option value="por">Portuguese</option>
<option value="ron">Romanian</option>
<option value="rus">Russian</option>
<option value="spa">Spanish</option>
<option value="swe">Swedish</option>
<option value="tha">Thai</option>
<option value="tur">Turkish</option>
<option value="ukr">Ukrainian</option>
<option value="vie">Vietnamese</option>
</select>
<p class="mt-1 text-xs text-gray-400">
{{ _("pipelines.ocr_language_hint") }}
</p>
</div>
</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">{{ _("pipelines.enabled_label") }}</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;"
>{{ _("common.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;"
>{{ _("common.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;"
>{{ _("common.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 %}