|
@@ -1,10 +1,10 @@
|
|
|
-import { useState } from 'react'
|
|
|
|
|
|
|
+import { useState, useEffect } from 'react'
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
-import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
import { executionsApi } from '../api/workflows'
|
|
import { executionsApi } from '../api/workflows'
|
|
|
import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
|
|
import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
|
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon } from 'lucide-react'
|
|
|
|
|
|
|
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass } from 'lucide-react'
|
|
|
import clsx from 'clsx'
|
|
import clsx from 'clsx'
|
|
|
|
|
|
|
|
// Helper to safely format timestamps that might be invalid or too large
|
|
// Helper to safely format timestamps that might be invalid or too large
|
|
@@ -19,10 +19,30 @@ function safeFormatDistanceToNow(timestamp: number): string {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// An execution that has not finished has finishedAt 0, so its duration has to
|
|
|
|
|
+// be measured against now. "Running..." told you nothing about whether a run had
|
|
|
|
|
+// been going for two seconds or two hours.
|
|
|
|
|
+function formatElapsed(startedAt: number, finishedAt: number, now: number): string {
|
|
|
|
|
+ if (!startedAt) return '-'
|
|
|
|
|
+ const end = finishedAt && finishedAt > 0 ? finishedAt : now
|
|
|
|
|
+ const seconds = (end - startedAt) / 1000
|
|
|
|
|
+ if (seconds < 0) return '-'
|
|
|
|
|
+ if (seconds < 60) return `${seconds.toFixed(2)}s`
|
|
|
|
|
+ const minutes = Math.floor(seconds / 60)
|
|
|
|
|
+ const rest = Math.floor(seconds % 60)
|
|
|
|
|
+ if (minutes < 60) return `${minutes}m ${rest}s`
|
|
|
|
|
+ const hours = Math.floor(minutes / 60)
|
|
|
|
|
+ return `${hours}h ${minutes % 60}m`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Statuses a run can still leave on its own, so a Stop is meaningful.
|
|
|
|
|
+const IN_FLIGHT = ['running', 'pending', 'waiting', 'resuming']
|
|
|
|
|
+
|
|
|
const PAGE_SIZE = 20
|
|
const PAGE_SIZE = 20
|
|
|
|
|
|
|
|
export default function ExecutionsPage() {
|
|
export default function ExecutionsPage() {
|
|
|
const navigate = useNavigate()
|
|
const navigate = useNavigate()
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
const [page, setPage] = useState(1)
|
|
const [page, setPage] = useState(1)
|
|
|
|
|
|
|
|
// The workflow editor already renders an execution across the canvas with each
|
|
// The workflow editor already renders an execution across the canvas with each
|
|
@@ -43,6 +63,24 @@ export default function ExecutionsPage() {
|
|
|
// Subscribe to WebSocket for real-time updates instead of polling
|
|
// Subscribe to WebSocket for real-time updates instead of polling
|
|
|
useExecutionListUpdates()
|
|
useExecutionListUpdates()
|
|
|
|
|
|
|
|
|
|
+ // A ticking clock only while something is actually in flight. Without the
|
|
|
|
|
+ // guard this would re-render the page once a second for ever on a list of
|
|
|
|
|
+ // finished runs.
|
|
|
|
|
+ const [now, setNow] = useState(() => Date.now())
|
|
|
|
|
+ const hasInFlight = (data?.executions || []).some((e: any) => IN_FLIGHT.includes(e.status))
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!hasInFlight) return
|
|
|
|
|
+ const timer = setInterval(() => setNow(Date.now()), 1000)
|
|
|
|
|
+ return () => clearInterval(timer)
|
|
|
|
|
+ }, [hasInFlight])
|
|
|
|
|
+
|
|
|
|
|
+ const cancelMutation = useMutation({
|
|
|
|
|
+ mutationFn: (id: string) => executionsApi.cancel(id),
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['executions'] })
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
const executions = data?.executions || []
|
|
const executions = data?.executions || []
|
|
|
const total = data?.total || 0
|
|
const total = data?.total || 0
|
|
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
|
@@ -58,6 +96,11 @@ export default function ExecutionsPage() {
|
|
|
return <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
|
|
return <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
|
|
|
case 'cancelled':
|
|
case 'cancelled':
|
|
|
return <Ban className="w-5 h-5 text-gray-500" />
|
|
return <Ban className="w-5 h-5 text-gray-500" />
|
|
|
|
|
+ case 'waiting':
|
|
|
|
|
+ // Distinct from running on purpose: a waiting run is not working, it
|
|
|
|
|
+ // needs an answer, and stopping it is a different act from stopping
|
|
|
|
|
+ // something mid-flight.
|
|
|
|
|
+ return <Hourglass className="w-5 h-5 text-amber-500" />
|
|
|
default:
|
|
default:
|
|
|
return <Clock className="w-5 h-5 text-gray-400" />
|
|
return <Clock className="w-5 h-5 text-gray-400" />
|
|
|
}
|
|
}
|
|
@@ -73,6 +116,8 @@ export default function ExecutionsPage() {
|
|
|
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
|
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
|
|
case 'cancelled':
|
|
case 'cancelled':
|
|
|
return 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-400'
|
|
return 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-400'
|
|
|
|
|
+ case 'waiting':
|
|
|
|
|
+ return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
|
|
default:
|
|
default:
|
|
|
return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
|
|
return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
|
|
|
}
|
|
}
|
|
@@ -123,6 +168,9 @@ export default function ExecutionsPage() {
|
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
|
Duration
|
|
Duration
|
|
|
</th>
|
|
</th>
|
|
|
|
|
+ <th className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
|
|
|
+ Actions
|
|
|
|
|
+ </th>
|
|
|
</tr>
|
|
</tr>
|
|
|
</thead>
|
|
</thead>
|
|
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
@@ -186,11 +234,26 @@ export default function ExecutionsPage() {
|
|
|
: '-'}
|
|
: '-'}
|
|
|
</td>
|
|
</td>
|
|
|
<td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
|
|
<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...'
|
|
|
|
|
- : '-'}
|
|
|
|
|
|
|
+ <span className={clsx(IN_FLIGHT.includes(execution.status) && 'tabular-nums')}>
|
|
|
|
|
+ {formatElapsed(execution.startedAt, execution.finishedAt, now)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 align-top text-right">
|
|
|
|
|
+ {IN_FLIGHT.includes(execution.status) && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ // The row opens the execution; the button must not.
|
|
|
|
|
+ e.stopPropagation()
|
|
|
|
|
+ cancelMutation.mutate(execution.id)
|
|
|
|
|
+ }}
|
|
|
|
|
+ disabled={cancelMutation.isPending}
|
|
|
|
|
+ title="Stop this execution. The runner stops at the next node boundary"
|
|
|
|
|
+ className="inline-flex items-center gap-1.5 px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Square className="w-3 h-3" />
|
|
|
|
|
+ Stop
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
</td>
|
|
</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
))}
|
|
))}
|