---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Workflows - Service Collections">
<div class="py-10" x-data="workflowsPage()" x-init="init()">
<header>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold leading-tight text-gray-900">Workflow Operations</h1>
<p class="mt-2 text-sm text-gray-600">
Monitor and manage infrastructure operations and approvals
</p>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
<button @click="refreshData()"
:disabled="loading"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50">
<svg class="mr-2 h-4 w-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
</div>
</div>
</header>
<main>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Error Alert -->
<template x-if="error">
<div class="mt-4 rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800" x-text="error"></p>
</div>
<div class="ml-auto pl-3">
<button @click="error = null" class="text-red-500 hover:text-red-700">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</template>
<!-- Status Overview -->
<div class="mt-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5">
<!-- Queued -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Queued</dt>
<dd class="text-lg font-medium text-gray-900" x-text="stats.queued"></dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Pending Approval -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-yellow-100 flex items-center justify-center">
<svg class="h-4 w-4 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd class="text-lg font-medium text-gray-900" x-text="stats.pending_approval"></dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Executing -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center">
<svg class="h-4 w-4 text-blue-600 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Executing</dt>
<dd class="text-lg font-medium text-gray-900" x-text="stats.executing"></dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Completed -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center">
<svg class="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Completed</dt>
<dd class="text-lg font-medium text-gray-900" x-text="stats.completed"></dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Failed -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-red-100 flex items-center justify-center">
<svg class="h-4 w-4 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Failed</dt>
<dd class="text-lg font-medium text-gray-900" x-text="stats.failed"></dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="mt-8 bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label for="status-filter" class="block text-sm font-medium text-gray-700">Status</label>
<select id="status-filter" x-model="filters.status" @change="loadOperations()"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md">
<option value="">All Statuses</option>
<option value="queued">Queued</option>
<option value="pending_approval">Pending Approval</option>
<option value="approved">Approved</option>
<option value="executing">Executing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label for="operation-filter" class="block text-sm font-medium text-gray-700">Operation Type</label>
<select id="operation-filter" x-model="filters.operation_type" @change="loadOperations()"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md">
<option value="">All Operations</option>
<option value="create_instance">Create Instance</option>
<option value="delete_instance">Delete Instance</option>
<option value="create_domain">Create Domain</option>
<option value="delete_domain">Delete Domain</option>
<option value="create_dns_record">Create DNS Record</option>
<option value="update_dns_record">Update DNS Record</option>
<option value="delete_dns_record">Delete DNS Record</option>
<option value="start_instance">Start Instance</option>
<option value="stop_instance">Stop Instance</option>
<option value="reboot_instance">Reboot Instance</option>
</select>
</div>
<div>
<label for="collection-filter" class="block text-sm font-medium text-gray-700">Collection</label>
<select id="collection-filter" x-model="filters.collection_id" @change="loadOperations()"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md">
<option value="">All Collections</option>
<template x-for="coll in collections" :key="coll.id">
<option :value="coll.id" x-text="coll.name"></option>
</template>
</select>
</div>
<div class="flex items-end">
<button @click="clearFilters()"
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
Clear Filters
</button>
</div>
</div>
</div>
</div>
<!-- Operations List -->
<div class="mt-8 bg-white shadow overflow-hidden sm:rounded-md">
<!-- Loading State -->
<template x-if="loading && operations.length === 0">
<div class="p-8 text-center">
<svg class="animate-spin h-8 w-8 text-primary-500 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-4 text-sm text-gray-500">Loading operations...</p>
</div>
</template>
<!-- Empty State -->
<template x-if="!loading && operations.length === 0">
<div class="p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No operations</h3>
<p class="mt-1 text-sm text-gray-500">No workflow operations match your filters.</p>
</div>
</template>
<!-- Operations List -->
<ul role="list" class="divide-y divide-gray-200" x-show="operations.length > 0">
<template x-for="op in operations" :key="op.id">
<li>
<div class="px-4 py-4 flex items-center justify-between hover:bg-gray-50">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full flex items-center justify-center"
:class="getStatusBgClass(op.status)">
<template x-if="op.status === 'executing'">
<svg class="h-5 w-5 animate-spin" :class="getStatusIconClass(op.status)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</template>
<template x-if="op.status === 'completed'">
<svg class="h-5 w-5" :class="getStatusIconClass(op.status)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</template>
<template x-if="op.status === 'failed'">
<svg class="h-5 w-5" :class="getStatusIconClass(op.status)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</template>
<template x-if="op.status === 'pending_approval'">
<svg class="h-5 w-5" :class="getStatusIconClass(op.status)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
<template x-if="['queued', 'approved', 'cancelled', 'rejected'].includes(op.status)">
<svg class="h-5 w-5" :class="getStatusIconClass(op.status)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</template>
</div>
</div>
<div class="ml-4">
<div class="flex items-center">
<p class="text-sm font-medium text-gray-900" x-text="formatOperationType(op.operation_type)"></p>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getStatusBadgeClass(op.status)"
x-text="formatStatus(op.status)">
</span>
</div>
<div class="mt-1">
<p class="text-sm text-gray-600">
<span x-text="op.service_collection_name || 'Unknown Collection'"></span>
<span class="mx-1">•</span>
<span x-text="op.resource_type"></span>
</p>
<p class="text-xs text-gray-500">
<span x-text="op.requested_by_email ? `Requested by ${op.requested_by_email}` : ''"></span>
<span class="mx-1">•</span>
<span x-text="formatTimeAgo(op.created_at)"></span>
</p>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button type="button"
@click="viewOperation(op)"
class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
View Details
</button>
<template x-if="op.status === 'pending_approval'">
<div class="flex space-x-2">
<button type="button"
@click="approveOperation(op.id)"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
Approve
</button>
<button type="button"
@click="rejectOperation(op.id)"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Reject
</button>
</div>
</template>
<template x-if="op.status === 'approved'">
<button type="button"
@click="executeOperation(op.id)"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Execute
</button>
</template>
<template x-if="op.status === 'failed'">
<button type="button"
@click="retryOperation(op.id)"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Retry
</button>
</template>
<template x-if="['queued', 'pending_approval', 'approved'].includes(op.status)">
<button type="button"
@click="cancelOperation(op.id)"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Cancel
</button>
</template>
</div>
</div>
</li>
</template>
</ul>
</div>
<!-- Pagination -->
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 mt-8 rounded-b-lg"
x-show="operations.length > 0">
<div class="flex-1 flex justify-between sm:hidden">
<button @click="prevPage()" :disabled="pagination.offset === 0"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50">
Previous
</button>
<button @click="nextPage()" :disabled="!hasMorePages"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50">
Next
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium" x-text="pagination.offset + 1"></span>
to
<span class="font-medium" x-text="Math.min(pagination.offset + pagination.limit, stats.total_operations || operations.length)"></span>
of
<span class="font-medium" x-text="stats.total_operations || operations.length"></span>
results
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button @click="prevPage()" :disabled="pagination.offset === 0"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
Page <span x-text="currentPage"></span>
</span>
<button @click="nextPage()" :disabled="!hasMorePages"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
<!-- Operation Details Modal -->
<div x-show="selectedOperation"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
@click="selectedOperation = null"
aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Operation Details
</h3>
<div class="mt-4 space-y-4">
<template x-if="selectedOperation">
<div class="space-y-3">
<div>
<dt class="text-sm font-medium text-gray-500">Operation Type</dt>
<dd class="text-sm text-gray-900" x-text="formatOperationType(selectedOperation.operation_type)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getStatusBadgeClass(selectedOperation.status)"
x-text="formatStatus(selectedOperation.status)"></span>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Collection</dt>
<dd class="text-sm text-gray-900" x-text="selectedOperation.service_collection_name || 'N/A'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Requested By</dt>
<dd class="text-sm text-gray-900" x-text="selectedOperation.requested_by_email || 'N/A'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="text-sm text-gray-900" x-text="formatDate(selectedOperation.created_at)"></dd>
</div>
<div x-show="selectedOperation.error_message">
<dt class="text-sm font-medium text-gray-500">Error</dt>
<dd class="text-sm text-red-600" x-text="selectedOperation.error_message"></dd>
</div>
<div x-show="selectedOperation.execution_logs">
<dt class="text-sm font-medium text-gray-500">Logs</dt>
<dd class="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded font-mono text-xs whitespace-pre-wrap"
x-text="selectedOperation.execution_logs"></dd>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button"
@click="selectedOperation = null"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</Layout>
<script is:inline>
// This script uses is:inline to execute immediately before Alpine processes x-data
// It relies on window.apiFetch which is defined globally in Layout.astro
// Define the workflowsPage component
function workflowsPage() {
return {
operations: [],
collections: [],
stats: {
queued: 0,
pending_approval: 0,
approved: 0,
executing: 0,
completed: 0,
failed: 0,
cancelled: 0,
total_operations: 0
},
filters: {
status: '',
operation_type: '',
collection_id: ''
},
pagination: {
limit: 20,
offset: 0
},
loading: false,
error: null,
selectedOperation: null,
get currentPage() {
return Math.floor(this.pagination.offset / this.pagination.limit) + 1;
},
get hasMorePages() {
return this.operations.length >= this.pagination.limit;
},
async init() {
await this.loadCollections();
await this.loadData();
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadStats(),
this.loadOperations()
]);
} catch (error) {
console.error('Error loading workflow data:', error);
this.error = error.message || 'Failed to load workflow data';
} finally {
this.loading = false;
}
},
async loadCollections() {
try {
const response = await window.apiFetch('/api/collections');
if (response.ok) {
this.collections = await response.json();
}
} catch (error) {
console.error('Error loading collections:', error);
}
},
async loadStats() {
const params = new URLSearchParams();
if (this.filters.collection_id) {
params.append('collection_id', this.filters.collection_id);
}
const url = '/api/workflows/stats' + (params.toString() ? '?' + params.toString() : '');
const response = await window.apiFetch(url);
if (!response.ok) {
throw new Error('Failed to load stats');
}
this.stats = await response.json();
},
async loadOperations() {
const params = new URLSearchParams();
params.append('limit', this.pagination.limit.toString());
params.append('offset', this.pagination.offset.toString());
if (this.filters.status) {
params.append('status', this.filters.status);
}
if (this.filters.operation_type) {
params.append('operation_type', this.filters.operation_type);
}
if (this.filters.collection_id) {
params.append('collection_id', this.filters.collection_id);
}
const response = await window.apiFetch('/api/workflows/operations?' + params.toString());
if (!response.ok) {
throw new Error('Failed to load operations');
}
this.operations = await response.json();
},
async refreshData() {
await this.loadData();
},
clearFilters() {
this.filters = {
status: '',
operation_type: '',
collection_id: ''
};
this.pagination.offset = 0;
this.loadOperations();
},
prevPage() {
if (this.pagination.offset > 0) {
this.pagination.offset -= this.pagination.limit;
this.loadOperations();
}
},
nextPage() {
if (this.hasMorePages) {
this.pagination.offset += this.pagination.limit;
this.loadOperations();
}
},
viewOperation(op) {
this.selectedOperation = op;
},
async executeOperation(operationId) {
try {
const response = await window.apiFetch(`/api/workflows/operations/${operationId}/execute`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to execute operation');
}
await this.loadData();
} catch (error) {
console.error('Error executing operation:', error);
this.error = error.message;
}
},
async approveOperation(operationId) {
try {
const response = await window.apiFetch(`/api/workflows/operations/${operationId}/approve`, {
method: 'POST',
body: JSON.stringify({ decision: 'approved', reason: 'Approved via UI' })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to approve operation');
}
await this.loadData();
} catch (error) {
console.error('Error approving operation:', error);
this.error = error.message;
}
},
async rejectOperation(operationId) {
const reason = prompt('Enter rejection reason:');
if (reason === null) return;
try {
const response = await window.apiFetch(`/api/workflows/operations/${operationId}/reject`, {
method: 'POST',
body: JSON.stringify({ decision: 'rejected', reason: reason || 'Rejected via UI' })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to reject operation');
}
await this.loadData();
} catch (error) {
console.error('Error rejecting operation:', error);
this.error = error.message;
}
},
async cancelOperation(operationId) {
if (!confirm('Are you sure you want to cancel this operation?')) return;
try {
const response = await window.apiFetch(`/api/workflows/operations/${operationId}/cancel`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to cancel operation');
}
await this.loadData();
} catch (error) {
console.error('Error cancelling operation:', error);
this.error = error.message;
}
},
async retryOperation(operationId) {
try {
const response = await window.apiFetch(`/api/workflows/operations/${operationId}/retry`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to retry operation');
}
await this.loadData();
} catch (error) {
console.error('Error retrying operation:', error);
this.error = error.message;
}
},
// Formatting helpers
formatOperationType(type) {
return type
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
},
formatStatus(status) {
return status
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
},
formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
},
formatTimeAgo(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return 'Just now';
},
getStatusBgClass(status) {
const classes = {
'queued': 'bg-gray-100',
'pending_approval': 'bg-yellow-100',
'approved': 'bg-blue-100',
'executing': 'bg-purple-100',
'completed': 'bg-green-100',
'failed': 'bg-red-100',
'cancelled': 'bg-gray-100',
'rejected': 'bg-red-100'
};
return classes[status] || 'bg-gray-100';
},
getStatusIconClass(status) {
const classes = {
'queued': 'text-gray-600',
'pending_approval': 'text-yellow-600',
'approved': 'text-blue-600',
'executing': 'text-purple-600',
'completed': 'text-green-600',
'failed': 'text-red-600',
'cancelled': 'text-gray-600',
'rejected': 'text-red-600'
};
return classes[status] || 'text-gray-600';
},
getStatusBadgeClass(status) {
const classes = {
'queued': 'bg-gray-100 text-gray-800',
'pending_approval': 'bg-yellow-100 text-yellow-800',
'approved': 'bg-blue-100 text-blue-800',
'executing': 'bg-purple-100 text-purple-800',
'completed': 'bg-green-100 text-green-800',
'failed': 'bg-red-100 text-red-800',
'cancelled': 'bg-gray-100 text-gray-800',
'rejected': 'bg-red-100 text-red-800'
};
return classes[status] || 'bg-gray-100 text-gray-800';
}
}
}
// Register the component with Alpine.js
// With is:inline, this script executes immediately (synchronously) when parsed,
// which is BEFORE Alpine.js initializes and processes x-data attributes.
// Make the function globally available so x-data="workflowsPage()" can find it
window.workflowsPage = workflowsPage;
// Also register with Alpine.data for proper Alpine integration
document.addEventListener('alpine:init', () => {
Alpine.data('workflowsPage', workflowsPage);
});
</script>
<style>
[x-cloak] { display: none !important; }
</style>