ExecutionsPage.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. import { useState, useEffect, useMemo } from 'react'
  2. import { useNavigate, useSearchParams } from 'react-router-dom'
  3. import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
  4. import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '../api/workflows'
  5. import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
  6. import { toMillis } from '../utils/timestamps'
  7. import { formatDistanceToNow } from 'date-fns'
  8. import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass, Search, X } from 'lucide-react'
  9. import clsx from 'clsx'
  10. // Helper to safely format timestamps that might be invalid or too large
  11. function safeFormatDistanceToNow(timestamp: number): string {
  12. if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
  13. return 'unknown'
  14. }
  15. try {
  16. return formatDistanceToNow(timestamp, { addSuffix: true })
  17. } catch {
  18. return 'unknown'
  19. }
  20. }
  21. // An execution that has not finished has finishedAt 0, so its duration has to
  22. // be measured against now. "Running..." told you nothing about whether a run had
  23. // been going for two seconds or two hours.
  24. function formatElapsed(startedAt: number | null, finishedAt: number | null, now: number): string {
  25. if (!startedAt) return '-'
  26. const end = finishedAt ?? now
  27. const seconds = (end - startedAt) / 1000
  28. if (seconds < 0) return '-'
  29. if (seconds < 60) return `${seconds.toFixed(2)}s`
  30. const minutes = Math.floor(seconds / 60)
  31. const rest = Math.floor(seconds % 60)
  32. if (minutes < 60) return `${minutes}m ${rest}s`
  33. const hours = Math.floor(minutes / 60)
  34. return `${hours}h ${minutes % 60}m`
  35. }
  36. // Statuses a run can still leave on its own, so a Stop is meaningful.
  37. const IN_FLIGHT = ['running', 'pending', 'waiting', 'resuming']
  38. const PAGE_SIZE = 20
  39. // Offered as filters. "pending" and "resuming" are deliberately absent: they are
  40. // momentary states nobody can catch on purpose, and they are covered by the
  41. // in-flight handling elsewhere on the row.
  42. const STATUS_FILTERS = ['running', 'waiting', 'completed', 'failed', 'cancelled'] as const
  43. type RangeKey = 'any' | '15m' | '1h' | '24h' | '7d' | '30d'
  44. const RANGES: { key: RangeKey; label: string; ms: number }[] = [
  45. { key: 'any', label: 'Any time', ms: 0 },
  46. { key: '15m', label: 'Last 15 minutes', ms: 15 * 60 * 1000 },
  47. { key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 },
  48. { key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
  49. { key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
  50. { key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
  51. ]
  52. // The first two are ordered by the database over the whole result set. The
  53. // duration ones cannot be: duration is not a stored field, so nothing can sort
  54. // or page by it server-side, and they reorder the page in the browser instead.
  55. // The labels say so rather than letting the numbers imply a global ranking.
  56. type SortKey = 'newest' | 'oldest' | 'longest' | 'shortest'
  57. const SORTS: { key: SortKey; label: string }[] = [
  58. { key: 'newest', label: 'Newest first' },
  59. { key: 'oldest', label: 'Oldest first' },
  60. { key: 'longest', label: 'Longest duration (this page)' },
  61. { key: 'shortest', label: 'Shortest duration (this page)' },
  62. ]
  63. // Trigger types the engine writes itself, so they never appear among the node
  64. // definitions. Without these the trigger filter would be missing the most
  65. // common value of all, "manual".
  66. const ENGINE_TRIGGER_TYPES: { value: string; label: string }[] = [
  67. { value: 'manual', label: 'Manual' },
  68. { value: 'webhook', label: 'Webhook' },
  69. { value: 'schedule', label: 'Schedule' },
  70. { value: 'workflow', label: 'Called by another workflow' },
  71. { value: 'error-workflow', label: 'Error handler' },
  72. ]
  73. function durationOf(execution: ExecutionListItem, now: number): number {
  74. const started = toMillis(execution.startedAt)
  75. if (started === null) return -1
  76. return (toMillis(execution.finishedAt) ?? now) - started
  77. }
  78. export default function ExecutionsPage() {
  79. const navigate = useNavigate()
  80. const queryClient = useQueryClient()
  81. const [searchParams, setSearchParams] = useSearchParams()
  82. // The URL is the single source of truth for the filters, so a filtered view
  83. // can be linked to and survives a reload.
  84. const search = searchParams.get('q') || ''
  85. const statuses = (searchParams.get('status') || '').split(',').filter(Boolean)
  86. const workflowId = searchParams.get('workflow') || ''
  87. const triggerType = searchParams.get('trigger') || ''
  88. const range = (searchParams.get('range') || 'any') as RangeKey
  89. const sort = (searchParams.get('sort') || 'newest') as SortKey
  90. const page = Math.max(1, Number(searchParams.get('page')) || 1)
  91. const hasFilters =
  92. search !== '' || statuses.length > 0 || workflowId !== '' || triggerType !== '' || range !== 'any'
  93. // Built from the previous params rather than from the ones this render closed
  94. // over: the search box writes on a timer, and a filter changed while that
  95. // timer was pending would otherwise be undone when it fired.
  96. const updateParams = (changes: Record<string, string | null>, keepPage = false) => {
  97. setSearchParams(previous => {
  98. const next = new URLSearchParams(previous)
  99. for (const [key, value] of Object.entries(changes)) {
  100. if (value === null || value === '') {
  101. next.delete(key)
  102. } else {
  103. next.set(key, value)
  104. }
  105. }
  106. // Any change to what is being asked for invalidates the page number: page
  107. // 7 of the old result set says nothing about the new one.
  108. if (!keepPage) {
  109. next.delete('page')
  110. }
  111. return next
  112. }, { replace: true })
  113. }
  114. // The search box is typed into, so it holds its own value and pushes it to the
  115. // URL once typing stops. Writing every keystroke to the URL would fire a query
  116. // per character.
  117. const [searchInput, setSearchInput] = useState(search)
  118. useEffect(() => {
  119. setSearchInput(search)
  120. }, [search])
  121. useEffect(() => {
  122. if (searchInput === search) return
  123. const timer = setTimeout(() => updateParams({ q: searchInput }), 300)
  124. return () => clearTimeout(timer)
  125. }, [searchInput])
  126. // The range presets are relative to now, but the boundary is fixed when the
  127. // range is chosen and not recomputed per render or per page. A sliding
  128. // boundary would move the result set underneath the pagination, so paging
  129. // through "Last hour" would skip and repeat rows.
  130. const startedAfter = useMemo(() => {
  131. const found = RANGES.find(r => r.key === range)
  132. if (!found || found.ms === 0) return undefined
  133. return Date.now() - found.ms
  134. }, [range])
  135. // The workflow editor already renders an execution across the canvas with each
  136. // node's input and output, so open it there rather than building a second
  137. // viewer that would show less.
  138. const openExecution = (execution: { id: string; workflowId: string }) => {
  139. if (!execution.workflowId) {
  140. return
  141. }
  142. navigate(`/workflows/${execution.workflowId}?execution=${encodeURIComponent(execution.id)}`)
  143. }
  144. const { data, isLoading, isFetching, refetch } = useQuery({
  145. // sort belongs in the key because "oldest first" is a different request, not
  146. // a different rendering of the same one.
  147. queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page }],
  148. queryFn: () =>
  149. executionsApi.query({
  150. search,
  151. status: statuses,
  152. triggerType: triggerType ? [triggerType] : undefined,
  153. workflowId: workflowId || undefined,
  154. startedAfter,
  155. sortOrder: sort === 'oldest' ? 'asc' : 'desc',
  156. page,
  157. pageSize: PAGE_SIZE,
  158. }),
  159. // Without this the table empties on every filter change and the layout
  160. // jumps; the old rows stay until the new ones arrive.
  161. placeholderData: keepPreviousData,
  162. })
  163. // Names for the workflow filter. A large pageSize because this is a picker,
  164. // not a listing, and paging it would hide workflows from the filter.
  165. const { data: workflowsData } = useQuery({
  166. queryKey: ['workflows', 'all-for-filter'],
  167. queryFn: () => workflowsApi.list(1, 200),
  168. staleTime: 5 * 60 * 1000,
  169. })
  170. // Trigger node definitions, to name the trigger types with something a person
  171. // recognises rather than the node id stored on the execution.
  172. const { data: nodesData } = useQuery({
  173. queryKey: ['node-definitions'],
  174. queryFn: () => nodesApi.list(),
  175. staleTime: 5 * 60 * 1000,
  176. })
  177. const triggerOptions = useMemo(() => {
  178. const options = new Map<string, string>()
  179. for (const engine of ENGINE_TRIGGER_TYPES) {
  180. options.set(engine.value, engine.label)
  181. }
  182. for (const definition of nodesData?.nodes || []) {
  183. if (definition.isTrigger) {
  184. options.set(definition.id, definition.name || definition.id)
  185. }
  186. }
  187. // A run recorded under a trigger type this build no longer knows about would
  188. // otherwise silently vanish from the dropdown while still selected in the URL.
  189. if (triggerType && !options.has(triggerType)) {
  190. options.set(triggerType, triggerType)
  191. }
  192. return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
  193. }, [nodesData, triggerType])
  194. const workflowOptions = useMemo(() => {
  195. const options = new Map<string, string>()
  196. for (const workflow of workflowsData?.workflows || []) {
  197. options.set(workflow.id, workflow.name || workflow.id)
  198. }
  199. // Executions outlive the workflows that produced them, so a filtered link
  200. // can name a workflow that has since been deleted. Keep it selectable.
  201. if (workflowId && !options.has(workflowId)) {
  202. options.set(workflowId, workflowId)
  203. }
  204. return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
  205. }, [workflowsData, workflowId])
  206. // Subscribe to WebSocket for real-time updates instead of polling
  207. useExecutionListUpdates()
  208. // A ticking clock only while something is actually in flight. Without the
  209. // guard this would re-render the page once a second for ever on a list of
  210. // finished runs.
  211. const [now, setNow] = useState(() => Date.now())
  212. const hasInFlight = (data?.executions || []).some(e => IN_FLIGHT.includes(e.status))
  213. useEffect(() => {
  214. if (!hasInFlight) return
  215. const timer = setInterval(() => setNow(Date.now()), 1000)
  216. return () => clearInterval(timer)
  217. }, [hasInFlight])
  218. const cancelMutation = useMutation({
  219. mutationFn: (id: string) => executionsApi.cancel(id),
  220. onSuccess: () => {
  221. queryClient.invalidateQueries({ queryKey: ['executions'] })
  222. },
  223. })
  224. const total = data?.total || 0
  225. const totalPages = Math.ceil(total / PAGE_SIZE)
  226. const hasMore = data?.hasMore || false
  227. const executions = useMemo(() => {
  228. const rows = data?.executions || []
  229. if (sort !== 'longest' && sort !== 'shortest') {
  230. return rows
  231. }
  232. const direction = sort === 'longest' ? -1 : 1
  233. return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction)
  234. }, [data, sort, now])
  235. const toggleStatus = (status: string) => {
  236. const next = statuses.includes(status)
  237. ? statuses.filter(s => s !== status)
  238. : [...statuses, status]
  239. updateParams({ status: next.join(',') })
  240. }
  241. const clearFilters = () => {
  242. setSearchParams(new URLSearchParams(), { replace: true })
  243. }
  244. const getStatusIcon = (status: string) => {
  245. switch (status) {
  246. case 'completed':
  247. return <CheckCircle className="w-5 h-5 text-green-500" />
  248. case 'failed':
  249. return <XCircle className="w-5 h-5 text-red-500" />
  250. case 'running':
  251. return <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
  252. case 'cancelled':
  253. return <Ban className="w-5 h-5 text-gray-500" />
  254. case 'waiting':
  255. // Distinct from running on purpose: a waiting run is not working, it
  256. // needs an answer, and stopping it is a different act from stopping
  257. // something mid-flight.
  258. return <Hourglass className="w-5 h-5 text-amber-500" />
  259. default:
  260. return <Clock className="w-5 h-5 text-gray-400" />
  261. }
  262. }
  263. const getStatusBadgeClass = (status: string) => {
  264. switch (status) {
  265. case 'completed':
  266. return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
  267. case 'failed':
  268. return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
  269. case 'running':
  270. return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
  271. case 'cancelled':
  272. return 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-400'
  273. case 'waiting':
  274. return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
  275. default:
  276. return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
  277. }
  278. }
  279. const selectClass =
  280. 'px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200'
  281. return (
  282. <div className="h-full overflow-auto p-6">
  283. <div className="flex items-center justify-between mb-4">
  284. <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Executions</h1>
  285. <button
  286. onClick={() => refetch()}
  287. 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"
  288. >
  289. <RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />
  290. Refresh
  291. </button>
  292. </div>
  293. <div className="mb-4 space-y-3">
  294. <div className="flex flex-wrap items-center gap-2">
  295. <div className="relative">
  296. <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
  297. <input
  298. value={searchInput}
  299. onChange={e => setSearchInput(e.target.value)}
  300. placeholder="Search workflow name, or paste an execution id"
  301. className="w-80 pl-9 pr-8 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200 placeholder-gray-400"
  302. />
  303. {searchInput && (
  304. <button
  305. onClick={() => setSearchInput('')}
  306. title="Clear the search"
  307. className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
  308. >
  309. <X className="w-4 h-4" />
  310. </button>
  311. )}
  312. </div>
  313. <select
  314. value={workflowId}
  315. onChange={e => updateParams({ workflow: e.target.value })}
  316. className={selectClass}
  317. >
  318. <option value="">All workflows</option>
  319. {workflowOptions.map(([id, name]) => (
  320. <option key={id} value={id}>{name}</option>
  321. ))}
  322. </select>
  323. <select
  324. value={triggerType}
  325. onChange={e => updateParams({ trigger: e.target.value })}
  326. className={selectClass}
  327. >
  328. <option value="">All triggers</option>
  329. {triggerOptions.map(([value, label]) => (
  330. <option key={value} value={value}>{label}</option>
  331. ))}
  332. </select>
  333. <select
  334. value={range}
  335. onChange={e => updateParams({ range: e.target.value === 'any' ? null : e.target.value })}
  336. className={selectClass}
  337. >
  338. {RANGES.map(r => (
  339. <option key={r.key} value={r.key}>{r.label}</option>
  340. ))}
  341. </select>
  342. <select
  343. value={sort}
  344. onChange={e => updateParams({ sort: e.target.value === 'newest' ? null : e.target.value })}
  345. className={selectClass}
  346. >
  347. {SORTS.map(s => (
  348. <option key={s.key} value={s.key}>{s.label}</option>
  349. ))}
  350. </select>
  351. {hasFilters && (
  352. <button
  353. onClick={clearFilters}
  354. className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
  355. >
  356. <X className="w-4 h-4" />
  357. Clear filters
  358. </button>
  359. )}
  360. </div>
  361. <div className="flex flex-wrap items-center gap-2">
  362. {STATUS_FILTERS.map(status => (
  363. <button
  364. key={status}
  365. onClick={() => toggleStatus(status)}
  366. className={clsx(
  367. 'px-3 py-1 rounded-full text-xs font-medium border capitalize',
  368. statuses.includes(status)
  369. ? 'border-primary-500 ring-1 ring-primary-500 ' + getStatusBadgeClass(status)
  370. : 'border-gray-200 dark:border-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
  371. )}
  372. >
  373. {status}
  374. </button>
  375. ))}
  376. {statuses.length === 0 && (
  377. <span className="text-xs text-gray-400 dark:text-gray-500">All statuses</span>
  378. )}
  379. </div>
  380. </div>
  381. {isLoading ? (
  382. <div className="flex items-center justify-center py-12">
  383. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
  384. </div>
  385. ) : executions.length === 0 ? (
  386. <div className="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700">
  387. {/* Three different nothings, which need three different answers: an
  388. empty page past the end of the results, a filter that excludes
  389. everything, and a platform on which nothing has ever run. */}
  390. {page > 1 ? (
  391. <>
  392. <p className="text-gray-500 dark:text-gray-400">
  393. Nothing on page {page}. The results may have changed since this link was made.
  394. </p>
  395. <button
  396. onClick={() => updateParams({ page: null }, false)}
  397. className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
  398. >
  399. Back to the first page
  400. </button>
  401. </>
  402. ) : hasFilters ? (
  403. <>
  404. <p className="text-gray-500 dark:text-gray-400">No executions match these filters.</p>
  405. <button
  406. onClick={clearFilters}
  407. className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
  408. >
  409. Clear filters
  410. </button>
  411. </>
  412. ) : (
  413. <>
  414. <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
  415. <p className="mt-1 text-sm text-gray-400 dark:text-gray-500">
  416. A run appears here as soon as a workflow is started, by hand or by a trigger.
  417. </p>
  418. </>
  419. )}
  420. </div>
  421. ) : (
  422. <>
  423. <div className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
  424. <table className="w-full">
  425. <thead className="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
  426. <tr>
  427. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  428. Status
  429. </th>
  430. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  431. Workflow
  432. </th>
  433. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  434. Trigger
  435. </th>
  436. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  437. Runner
  438. </th>
  439. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  440. Started
  441. </th>
  442. <th className="px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300">
  443. Duration
  444. </th>
  445. <th className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-300">
  446. Actions
  447. </th>
  448. </tr>
  449. </thead>
  450. <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
  451. {executions.map(execution => {
  452. const startedAt = toMillis(execution.startedAt)
  453. const finishedAt = toMillis(execution.finishedAt)
  454. return (
  455. <tr
  456. key={execution.id}
  457. onClick={() => openExecution(execution)}
  458. title="Open this execution in the workflow editor"
  459. className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
  460. >
  461. <td className="px-4 py-3 align-top">
  462. <div className="flex items-center gap-2">
  463. {getStatusIcon(execution.status)}
  464. <span
  465. className={clsx(
  466. 'px-2 py-0.5 rounded-full text-xs font-medium',
  467. getStatusBadgeClass(execution.status)
  468. )}
  469. >
  470. {execution.status}
  471. </span>
  472. </div>
  473. </td>
  474. <td className="px-4 py-3 align-top">
  475. <span className="font-medium text-gray-900 dark:text-gray-100">
  476. {execution.workflowName || execution.workflowId}
  477. </span>
  478. <div className="text-xs text-gray-400 dark:text-gray-500 font-mono">
  479. {execution.id}
  480. </div>
  481. {execution.error && (
  482. <div
  483. className="mt-1 flex items-start gap-1.5 text-red-600 dark:text-red-400 text-xs max-w-xl"
  484. title={execution.error}
  485. >
  486. <AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
  487. <span className="break-words">{execution.error}</span>
  488. </div>
  489. )}
  490. {/* A run ended by a stop node carries a message the same
  491. way a failed one carries an error, and it is the only
  492. record of why a completed run did not do the rest of
  493. its work. Shown in a neutral colour: this outcome was
  494. chosen, not suffered. */}
  495. {!execution.error && execution.stopReason && (
  496. <div
  497. className="mt-1 flex items-start gap-1.5 text-gray-600 dark:text-gray-400 text-xs max-w-xl"
  498. title={execution.stopReason}
  499. >
  500. <Octagon className="w-3.5 h-3.5 shrink-0 mt-0.5" />
  501. <span className="break-words">{execution.stopReason}</span>
  502. </div>
  503. )}
  504. </td>
  505. <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">
  506. {execution.triggerType || '-'}
  507. </td>
  508. <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400 text-sm">
  509. {execution.runnerId || '-'}
  510. </td>
  511. <td
  512. className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm"
  513. title={startedAt ? new Date(startedAt).toLocaleString() : undefined}
  514. >
  515. {startedAt ? safeFormatDistanceToNow(startedAt) : '-'}
  516. </td>
  517. <td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
  518. <span className={clsx(IN_FLIGHT.includes(execution.status) && 'tabular-nums')}>
  519. {formatElapsed(startedAt, finishedAt, now)}
  520. </span>
  521. </td>
  522. <td className="px-4 py-3 align-top text-right">
  523. {IN_FLIGHT.includes(execution.status) && (
  524. <button
  525. onClick={(e) => {
  526. // The row opens the execution; the button must not.
  527. e.stopPropagation()
  528. cancelMutation.mutate(execution.id)
  529. }}
  530. disabled={cancelMutation.isPending}
  531. title="Stop this execution. The runner stops at the next node boundary"
  532. 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"
  533. >
  534. <Square className="w-3 h-3" />
  535. Stop
  536. </button>
  537. )}
  538. </td>
  539. </tr>
  540. )
  541. })}
  542. </tbody>
  543. </table>
  544. </div>
  545. {(sort === 'longest' || sort === 'shortest') && (
  546. <p className="mt-2 px-2 text-xs text-gray-500 dark:text-gray-400">
  547. Duration is not stored, so it can only be ordered within the {executions.length} rows
  548. on this page - not across all {total} results.
  549. </p>
  550. )}
  551. {/* Pagination */}
  552. <div className="flex items-center justify-between mt-4 px-2">
  553. <div className="text-sm text-gray-600 dark:text-gray-400">
  554. Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total}
  555. {hasFilters ? ' matching executions' : ' executions'}
  556. </div>
  557. <div className="flex items-center gap-2">
  558. <button
  559. onClick={() => updateParams({ page: page > 2 ? String(page - 1) : null }, true)}
  560. disabled={page === 1}
  561. 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"
  562. >
  563. <ChevronLeft className="w-4 h-4" />
  564. Previous
  565. </button>
  566. <span className="text-sm text-gray-600 dark:text-gray-400">
  567. Page {page} of {totalPages || 1}
  568. </span>
  569. <button
  570. onClick={() => updateParams({ page: String(page + 1) }, true)}
  571. disabled={!hasMore && page >= totalPages}
  572. 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"
  573. >
  574. Next
  575. <ChevronRight className="w-4 h-4" />
  576. </button>
  577. </div>
  578. </div>
  579. </>
  580. )}
  581. </div>
  582. )
  583. }