| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- import { useState } from 'react'
- import { useNavigate } from 'react-router-dom'
- import { useQuery } from '@tanstack/react-query'
- import { executionsApi } from '../api/workflows'
- import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
- import { formatDistanceToNow } from 'date-fns'
- import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon } from 'lucide-react'
- import clsx from 'clsx'
- // Helper to safely format timestamps that might be invalid or too large
- function safeFormatDistanceToNow(timestamp: number): string {
- if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
- return 'unknown'
- }
- try {
- return formatDistanceToNow(timestamp, { addSuffix: true })
- } catch {
- return 'unknown'
- }
- }
- const PAGE_SIZE = 20
- export default function ExecutionsPage() {
- const navigate = useNavigate()
- const [page, setPage] = useState(1)
- // The workflow editor already renders an execution across the canvas with each
- // node's input and output, so open it there rather than building a second
- // viewer that would show less.
- const openExecution = (execution: { id: string; workflowId: string }) => {
- if (!execution.workflowId) {
- return
- }
- navigate(`/workflows/${execution.workflowId}?execution=${encodeURIComponent(execution.id)}`)
- }
- const { data, isLoading, refetch } = useQuery({
- queryKey: ['executions', page],
- queryFn: () => executionsApi.list(undefined, undefined, page, PAGE_SIZE),
- })
- // Subscribe to WebSocket for real-time updates instead of polling
- useExecutionListUpdates()
- const executions = data?.executions || []
- const total = data?.total || 0
- const totalPages = Math.ceil(total / PAGE_SIZE)
- const hasMore = data?.hasMore || false
- const getStatusIcon = (status: string) => {
- switch (status) {
- case 'completed':
- return <CheckCircle className="w-5 h-5 text-green-500" />
- case 'failed':
- return <XCircle className="w-5 h-5 text-red-500" />
- case 'running':
- return <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
- case 'cancelled':
- return <Ban className="w-5 h-5 text-gray-500" />
- default:
- return <Clock className="w-5 h-5 text-gray-400" />
- }
- }
- const getStatusBadgeClass = (status: string) => {
- switch (status) {
- case 'completed':
- return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
- case 'failed':
- return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
- case 'running':
- return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
- case 'cancelled':
- return 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-400'
- default:
- return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
- }
- }
- return (
- <div className="h-full overflow-auto p-6">
- <div className="flex items-center justify-between mb-6">
- <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Executions</h1>
- <button
- onClick={() => refetch()}
- className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
- >
- <RefreshCw className="w-4 h-4" />
- Refresh
- </button>
- </div>
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
- </div>
- ) : executions.length === 0 ? (
- <div className="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700">
- <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
- </div>
- ) : (
- <>
- <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
- <table className="w-full">
- <thead className="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
- <tr>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Status
- </th>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Workflow
- </th>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Trigger
- </th>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Runner
- </th>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Started
- </th>
- <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
- Duration
- </th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
- {executions.map((execution: any) => (
- <tr
- key={execution.id}
- onClick={() => openExecution(execution)}
- title="Open this execution in the workflow editor"
- className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
- >
- <td className="px-4 py-3 align-top">
- <div className="flex items-center gap-2">
- {getStatusIcon(execution.status)}
- <span
- className={clsx(
- 'px-2 py-0.5 rounded-full text-xs font-medium',
- getStatusBadgeClass(execution.status)
- )}
- >
- {execution.status}
- </span>
- </div>
- </td>
- <td className="px-4 py-3 align-top">
- <span className="font-medium text-gray-900 dark:text-gray-100">
- {execution.workflowName || execution.workflowId}
- </span>
- {execution.error && (
- <div
- className="mt-1 flex items-start gap-1.5 text-red-600 dark:text-red-400 text-xs max-w-xl"
- title={execution.error}
- >
- <AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
- <span className="break-words">{execution.error}</span>
- </div>
- )}
- {/* A run ended by a stop node carries a message the same
- way a failed one carries an error, and it is the only
- record of why a completed run did not do the rest of
- its work. Shown in a neutral colour: this outcome was
- chosen, not suffered. */}
- {!execution.error && execution.stopReason && (
- <div
- className="mt-1 flex items-start gap-1.5 text-gray-600 dark:text-gray-400 text-xs max-w-xl"
- title={execution.stopReason}
- >
- <Octagon className="w-3.5 h-3.5 shrink-0 mt-0.5" />
- <span className="break-words">{execution.stopReason}</span>
- </div>
- )}
- </td>
- <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">
- {execution.triggerType}
- </td>
- <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400 text-sm">
- {execution.runnerId || '-'}
- </td>
- <td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
- {execution.startedAt
- ? safeFormatDistanceToNow(execution.startedAt)
- : '-'}
- </td>
- <td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
- {execution.finishedAt && execution.startedAt
- ? `${((execution.finishedAt - execution.startedAt) / 1000).toFixed(2)}s`
- : execution.status === 'running'
- ? 'Running...'
- : '-'}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- {/* Pagination */}
- <div className="flex items-center justify-between mt-4 px-2">
- <div className="text-sm text-gray-600 dark:text-gray-400">
- Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total} executions
- </div>
- <div className="flex items-center gap-2">
- <button
- onClick={() => setPage(p => Math.max(1, p - 1))}
- disabled={page === 1}
- className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <ChevronLeft className="w-4 h-4" />
- Previous
- </button>
- <span className="text-sm text-gray-600 dark:text-gray-400">
- Page {page} of {totalPages || 1}
- </span>
- <button
- onClick={() => setPage(p => p + 1)}
- disabled={!hasMore && page >= totalPages}
- className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Next
- <ChevronRight className="w-4 h-4" />
- </button>
- </div>
- </div>
- </>
- )}
- </div>
- )
- }
|