|
|
@@ -1,10 +1,11 @@
|
|
|
-import { useState, useEffect } from 'react'
|
|
|
-import { useNavigate } from 'react-router-dom'
|
|
|
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
-import { executionsApi } from '../api/workflows'
|
|
|
+import { useState, useEffect, useMemo } from 'react'
|
|
|
+import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
+import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
|
|
|
+import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '../api/workflows'
|
|
|
import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
|
|
|
+import { toMillis } from '../utils/timestamps'
|
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass } from 'lucide-react'
|
|
|
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass, Search, X } from 'lucide-react'
|
|
|
import clsx from 'clsx'
|
|
|
|
|
|
// Helper to safely format timestamps that might be invalid or too large
|
|
|
@@ -22,9 +23,9 @@ 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 {
|
|
|
+function formatElapsed(startedAt: number | null, finishedAt: number | null, now: number): string {
|
|
|
if (!startedAt) return '-'
|
|
|
- const end = finishedAt && finishedAt > 0 ? finishedAt : now
|
|
|
+ const end = finishedAt ?? now
|
|
|
const seconds = (end - startedAt) / 1000
|
|
|
if (seconds < 0) return '-'
|
|
|
if (seconds < 60) return `${seconds.toFixed(2)}s`
|
|
|
@@ -40,10 +41,114 @@ const IN_FLIGHT = ['running', 'pending', 'waiting', 'resuming']
|
|
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
|
|
+// Offered as filters. "pending" and "resuming" are deliberately absent: they are
|
|
|
+// momentary states nobody can catch on purpose, and they are covered by the
|
|
|
+// in-flight handling elsewhere on the row.
|
|
|
+const STATUS_FILTERS = ['running', 'waiting', 'completed', 'failed', 'cancelled'] as const
|
|
|
+
|
|
|
+type RangeKey = 'any' | '15m' | '1h' | '24h' | '7d' | '30d'
|
|
|
+
|
|
|
+const RANGES: { key: RangeKey; label: string; ms: number }[] = [
|
|
|
+ { key: 'any', label: 'Any time', ms: 0 },
|
|
|
+ { key: '15m', label: 'Last 15 minutes', ms: 15 * 60 * 1000 },
|
|
|
+ { key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 },
|
|
|
+ { key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
|
|
|
+ { key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
|
|
|
+ { key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
|
|
+]
|
|
|
+
|
|
|
+// The first two are ordered by the database over the whole result set. The
|
|
|
+// duration ones cannot be: duration is not a stored field, so nothing can sort
|
|
|
+// or page by it server-side, and they reorder the page in the browser instead.
|
|
|
+// The labels say so rather than letting the numbers imply a global ranking.
|
|
|
+type SortKey = 'newest' | 'oldest' | 'longest' | 'shortest'
|
|
|
+
|
|
|
+const SORTS: { key: SortKey; label: string }[] = [
|
|
|
+ { key: 'newest', label: 'Newest first' },
|
|
|
+ { key: 'oldest', label: 'Oldest first' },
|
|
|
+ { key: 'longest', label: 'Longest duration (this page)' },
|
|
|
+ { key: 'shortest', label: 'Shortest duration (this page)' },
|
|
|
+]
|
|
|
+
|
|
|
+// Trigger types the engine writes itself, so they never appear among the node
|
|
|
+// definitions. Without these the trigger filter would be missing the most
|
|
|
+// common value of all, "manual".
|
|
|
+const ENGINE_TRIGGER_TYPES: { value: string; label: string }[] = [
|
|
|
+ { value: 'manual', label: 'Manual' },
|
|
|
+ { value: 'webhook', label: 'Webhook' },
|
|
|
+ { value: 'schedule', label: 'Schedule' },
|
|
|
+ { value: 'workflow', label: 'Called by another workflow' },
|
|
|
+ { value: 'error-workflow', label: 'Error handler' },
|
|
|
+]
|
|
|
+
|
|
|
+function durationOf(execution: ExecutionListItem, now: number): number {
|
|
|
+ const started = toMillis(execution.startedAt)
|
|
|
+ if (started === null) return -1
|
|
|
+ return (toMillis(execution.finishedAt) ?? now) - started
|
|
|
+}
|
|
|
+
|
|
|
export default function ExecutionsPage() {
|
|
|
const navigate = useNavigate()
|
|
|
const queryClient = useQueryClient()
|
|
|
- const [page, setPage] = useState(1)
|
|
|
+ const [searchParams, setSearchParams] = useSearchParams()
|
|
|
+
|
|
|
+ // The URL is the single source of truth for the filters, so a filtered view
|
|
|
+ // can be linked to and survives a reload.
|
|
|
+ const search = searchParams.get('q') || ''
|
|
|
+ const statuses = (searchParams.get('status') || '').split(',').filter(Boolean)
|
|
|
+ const workflowId = searchParams.get('workflow') || ''
|
|
|
+ const triggerType = searchParams.get('trigger') || ''
|
|
|
+ const range = (searchParams.get('range') || 'any') as RangeKey
|
|
|
+ const sort = (searchParams.get('sort') || 'newest') as SortKey
|
|
|
+ const page = Math.max(1, Number(searchParams.get('page')) || 1)
|
|
|
+
|
|
|
+ const hasFilters =
|
|
|
+ search !== '' || statuses.length > 0 || workflowId !== '' || triggerType !== '' || range !== 'any'
|
|
|
+
|
|
|
+ // Built from the previous params rather than from the ones this render closed
|
|
|
+ // over: the search box writes on a timer, and a filter changed while that
|
|
|
+ // timer was pending would otherwise be undone when it fired.
|
|
|
+ const updateParams = (changes: Record<string, string | null>, keepPage = false) => {
|
|
|
+ setSearchParams(previous => {
|
|
|
+ const next = new URLSearchParams(previous)
|
|
|
+ for (const [key, value] of Object.entries(changes)) {
|
|
|
+ if (value === null || value === '') {
|
|
|
+ next.delete(key)
|
|
|
+ } else {
|
|
|
+ next.set(key, value)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Any change to what is being asked for invalidates the page number: page
|
|
|
+ // 7 of the old result set says nothing about the new one.
|
|
|
+ if (!keepPage) {
|
|
|
+ next.delete('page')
|
|
|
+ }
|
|
|
+ return next
|
|
|
+ }, { replace: true })
|
|
|
+ }
|
|
|
+
|
|
|
+ // The search box is typed into, so it holds its own value and pushes it to the
|
|
|
+ // URL once typing stops. Writing every keystroke to the URL would fire a query
|
|
|
+ // per character.
|
|
|
+ const [searchInput, setSearchInput] = useState(search)
|
|
|
+ useEffect(() => {
|
|
|
+ setSearchInput(search)
|
|
|
+ }, [search])
|
|
|
+ useEffect(() => {
|
|
|
+ if (searchInput === search) return
|
|
|
+ const timer = setTimeout(() => updateParams({ q: searchInput }), 300)
|
|
|
+ return () => clearTimeout(timer)
|
|
|
+ }, [searchInput])
|
|
|
+
|
|
|
+ // The range presets are relative to now, but the boundary is fixed when the
|
|
|
+ // range is chosen and not recomputed per render or per page. A sliding
|
|
|
+ // boundary would move the result set underneath the pagination, so paging
|
|
|
+ // through "Last hour" would skip and repeat rows.
|
|
|
+ const startedAfter = useMemo(() => {
|
|
|
+ const found = RANGES.find(r => r.key === range)
|
|
|
+ if (!found || found.ms === 0) return undefined
|
|
|
+ return Date.now() - found.ms
|
|
|
+ }, [range])
|
|
|
|
|
|
// 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
|
|
|
@@ -55,11 +160,73 @@ export default function ExecutionsPage() {
|
|
|
navigate(`/workflows/${execution.workflowId}?execution=${encodeURIComponent(execution.id)}`)
|
|
|
}
|
|
|
|
|
|
- const { data, isLoading, refetch } = useQuery({
|
|
|
- queryKey: ['executions', page],
|
|
|
- queryFn: () => executionsApi.list(undefined, undefined, page, PAGE_SIZE),
|
|
|
+ const { data, isLoading, isFetching, refetch } = useQuery({
|
|
|
+ // sort belongs in the key because "oldest first" is a different request, not
|
|
|
+ // a different rendering of the same one.
|
|
|
+ queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page }],
|
|
|
+ queryFn: () =>
|
|
|
+ executionsApi.query({
|
|
|
+ search,
|
|
|
+ status: statuses,
|
|
|
+ triggerType: triggerType ? [triggerType] : undefined,
|
|
|
+ workflowId: workflowId || undefined,
|
|
|
+ startedAfter,
|
|
|
+ sortOrder: sort === 'oldest' ? 'asc' : 'desc',
|
|
|
+ page,
|
|
|
+ pageSize: PAGE_SIZE,
|
|
|
+ }),
|
|
|
+ // Without this the table empties on every filter change and the layout
|
|
|
+ // jumps; the old rows stay until the new ones arrive.
|
|
|
+ placeholderData: keepPreviousData,
|
|
|
+ })
|
|
|
+
|
|
|
+ // Names for the workflow filter. A large pageSize because this is a picker,
|
|
|
+ // not a listing, and paging it would hide workflows from the filter.
|
|
|
+ const { data: workflowsData } = useQuery({
|
|
|
+ queryKey: ['workflows', 'all-for-filter'],
|
|
|
+ queryFn: () => workflowsApi.list(1, 200),
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
})
|
|
|
|
|
|
+ // Trigger node definitions, to name the trigger types with something a person
|
|
|
+ // recognises rather than the node id stored on the execution.
|
|
|
+ const { data: nodesData } = useQuery({
|
|
|
+ queryKey: ['node-definitions'],
|
|
|
+ queryFn: () => nodesApi.list(),
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
+ })
|
|
|
+
|
|
|
+ const triggerOptions = useMemo(() => {
|
|
|
+ const options = new Map<string, string>()
|
|
|
+ for (const engine of ENGINE_TRIGGER_TYPES) {
|
|
|
+ options.set(engine.value, engine.label)
|
|
|
+ }
|
|
|
+ for (const definition of nodesData?.nodes || []) {
|
|
|
+ if (definition.isTrigger) {
|
|
|
+ options.set(definition.id, definition.name || definition.id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // A run recorded under a trigger type this build no longer knows about would
|
|
|
+ // otherwise silently vanish from the dropdown while still selected in the URL.
|
|
|
+ if (triggerType && !options.has(triggerType)) {
|
|
|
+ options.set(triggerType, triggerType)
|
|
|
+ }
|
|
|
+ return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
|
|
+ }, [nodesData, triggerType])
|
|
|
+
|
|
|
+ const workflowOptions = useMemo(() => {
|
|
|
+ const options = new Map<string, string>()
|
|
|
+ for (const workflow of workflowsData?.workflows || []) {
|
|
|
+ options.set(workflow.id, workflow.name || workflow.id)
|
|
|
+ }
|
|
|
+ // Executions outlive the workflows that produced them, so a filtered link
|
|
|
+ // can name a workflow that has since been deleted. Keep it selectable.
|
|
|
+ if (workflowId && !options.has(workflowId)) {
|
|
|
+ options.set(workflowId, workflowId)
|
|
|
+ }
|
|
|
+ return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
|
|
+ }, [workflowsData, workflowId])
|
|
|
+
|
|
|
// Subscribe to WebSocket for real-time updates instead of polling
|
|
|
useExecutionListUpdates()
|
|
|
|
|
|
@@ -67,7 +234,7 @@ export default function ExecutionsPage() {
|
|
|
// 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))
|
|
|
+ const hasInFlight = (data?.executions || []).some(e => IN_FLIGHT.includes(e.status))
|
|
|
useEffect(() => {
|
|
|
if (!hasInFlight) return
|
|
|
const timer = setInterval(() => setNow(Date.now()), 1000)
|
|
|
@@ -81,11 +248,30 @@ export default function ExecutionsPage() {
|
|
|
},
|
|
|
})
|
|
|
|
|
|
- const executions = data?.executions || []
|
|
|
const total = data?.total || 0
|
|
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
|
|
const hasMore = data?.hasMore || false
|
|
|
|
|
|
+ const executions = useMemo(() => {
|
|
|
+ const rows = data?.executions || []
|
|
|
+ if (sort !== 'longest' && sort !== 'shortest') {
|
|
|
+ return rows
|
|
|
+ }
|
|
|
+ const direction = sort === 'longest' ? -1 : 1
|
|
|
+ return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction)
|
|
|
+ }, [data, sort, now])
|
|
|
+
|
|
|
+ const toggleStatus = (status: string) => {
|
|
|
+ const next = statuses.includes(status)
|
|
|
+ ? statuses.filter(s => s !== status)
|
|
|
+ : [...statuses, status]
|
|
|
+ updateParams({ status: next.join(',') })
|
|
|
+ }
|
|
|
+
|
|
|
+ const clearFilters = () => {
|
|
|
+ setSearchParams(new URLSearchParams(), { replace: true })
|
|
|
+ }
|
|
|
+
|
|
|
const getStatusIcon = (status: string) => {
|
|
|
switch (status) {
|
|
|
case 'completed':
|
|
|
@@ -123,26 +309,156 @@ export default function ExecutionsPage() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ const selectClass =
|
|
|
+ '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'
|
|
|
+
|
|
|
return (
|
|
|
<div className="h-full overflow-auto p-6">
|
|
|
- <div className="flex items-center justify-between mb-6">
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
<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" />
|
|
|
+ <RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />
|
|
|
Refresh
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
+ <div className="mb-4 space-y-3">
|
|
|
+ <div className="flex flex-wrap items-center gap-2">
|
|
|
+ <div className="relative">
|
|
|
+ <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
|
+ <input
|
|
|
+ value={searchInput}
|
|
|
+ onChange={e => setSearchInput(e.target.value)}
|
|
|
+ placeholder="Search workflow name, or paste an execution id"
|
|
|
+ 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"
|
|
|
+ />
|
|
|
+ {searchInput && (
|
|
|
+ <button
|
|
|
+ onClick={() => setSearchInput('')}
|
|
|
+ title="Clear the search"
|
|
|
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <select
|
|
|
+ value={workflowId}
|
|
|
+ onChange={e => updateParams({ workflow: e.target.value })}
|
|
|
+ className={selectClass}
|
|
|
+ >
|
|
|
+ <option value="">All workflows</option>
|
|
|
+ {workflowOptions.map(([id, name]) => (
|
|
|
+ <option key={id} value={id}>{name}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+
|
|
|
+ <select
|
|
|
+ value={triggerType}
|
|
|
+ onChange={e => updateParams({ trigger: e.target.value })}
|
|
|
+ className={selectClass}
|
|
|
+ >
|
|
|
+ <option value="">All triggers</option>
|
|
|
+ {triggerOptions.map(([value, label]) => (
|
|
|
+ <option key={value} value={value}>{label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+
|
|
|
+ <select
|
|
|
+ value={range}
|
|
|
+ onChange={e => updateParams({ range: e.target.value === 'any' ? null : e.target.value })}
|
|
|
+ className={selectClass}
|
|
|
+ >
|
|
|
+ {RANGES.map(r => (
|
|
|
+ <option key={r.key} value={r.key}>{r.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+
|
|
|
+ <select
|
|
|
+ value={sort}
|
|
|
+ onChange={e => updateParams({ sort: e.target.value === 'newest' ? null : e.target.value })}
|
|
|
+ className={selectClass}
|
|
|
+ >
|
|
|
+ {SORTS.map(s => (
|
|
|
+ <option key={s.key} value={s.key}>{s.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+
|
|
|
+ {hasFilters && (
|
|
|
+ <button
|
|
|
+ onClick={clearFilters}
|
|
|
+ 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"
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4" />
|
|
|
+ Clear filters
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex flex-wrap items-center gap-2">
|
|
|
+ {STATUS_FILTERS.map(status => (
|
|
|
+ <button
|
|
|
+ key={status}
|
|
|
+ onClick={() => toggleStatus(status)}
|
|
|
+ className={clsx(
|
|
|
+ 'px-3 py-1 rounded-full text-xs font-medium border capitalize',
|
|
|
+ statuses.includes(status)
|
|
|
+ ? 'border-primary-500 ring-1 ring-primary-500 ' + getStatusBadgeClass(status)
|
|
|
+ : 'border-gray-200 dark:border-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {status}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ {statuses.length === 0 && (
|
|
|
+ <span className="text-xs text-gray-400 dark:text-gray-500">All statuses</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </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>
|
|
|
+ {/* Three different nothings, which need three different answers: an
|
|
|
+ empty page past the end of the results, a filter that excludes
|
|
|
+ everything, and a platform on which nothing has ever run. */}
|
|
|
+ {page > 1 ? (
|
|
|
+ <>
|
|
|
+ <p className="text-gray-500 dark:text-gray-400">
|
|
|
+ Nothing on page {page}. The results may have changed since this link was made.
|
|
|
+ </p>
|
|
|
+ <button
|
|
|
+ onClick={() => updateParams({ page: null }, false)}
|
|
|
+ className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
|
|
+ >
|
|
|
+ Back to the first page
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ ) : hasFilters ? (
|
|
|
+ <>
|
|
|
+ <p className="text-gray-500 dark:text-gray-400">No executions match these filters.</p>
|
|
|
+ <button
|
|
|
+ onClick={clearFilters}
|
|
|
+ className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
|
|
+ >
|
|
|
+ Clear filters
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
|
|
|
+ <p className="mt-1 text-sm text-gray-400 dark:text-gray-500">
|
|
|
+ A run appears here as soon as a workflow is started, by hand or by a trigger.
|
|
|
+ </p>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
</div>
|
|
|
) : (
|
|
|
<>
|
|
|
@@ -174,7 +490,10 @@ export default function ExecutionsPage() {
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
|
- {executions.map((execution: any) => (
|
|
|
+ {executions.map(execution => {
|
|
|
+ const startedAt = toMillis(execution.startedAt)
|
|
|
+ const finishedAt = toMillis(execution.finishedAt)
|
|
|
+ return (
|
|
|
<tr
|
|
|
key={execution.id}
|
|
|
onClick={() => openExecution(execution)}
|
|
|
@@ -198,6 +517,9 @@ export default function ExecutionsPage() {
|
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
|
{execution.workflowName || execution.workflowId}
|
|
|
</span>
|
|
|
+ <div className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
|
|
+ {execution.id}
|
|
|
+ </div>
|
|
|
{execution.error && (
|
|
|
<div
|
|
|
className="mt-1 flex items-start gap-1.5 text-red-600 dark:text-red-400 text-xs max-w-xl"
|
|
|
@@ -223,19 +545,20 @@ export default function ExecutionsPage() {
|
|
|
)}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">
|
|
|
- {execution.triggerType}
|
|
|
+ {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
|
|
|
+ className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm"
|
|
|
+ title={startedAt ? new Date(startedAt).toLocaleString() : undefined}
|
|
|
+ >
|
|
|
+ {startedAt ? safeFormatDistanceToNow(startedAt) : '-'}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
|
|
|
<span className={clsx(IN_FLIGHT.includes(execution.status) && 'tabular-nums')}>
|
|
|
- {formatElapsed(execution.startedAt, execution.finishedAt, now)}
|
|
|
+ {formatElapsed(startedAt, finishedAt, now)}
|
|
|
</span>
|
|
|
</td>
|
|
|
<td className="px-4 py-3 align-top text-right">
|
|
|
@@ -256,19 +579,28 @@ export default function ExecutionsPage() {
|
|
|
)}
|
|
|
</td>
|
|
|
</tr>
|
|
|
- ))}
|
|
|
+ )
|
|
|
+ })}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
+ {(sort === 'longest' || sort === 'shortest') && (
|
|
|
+ <p className="mt-2 px-2 text-xs text-gray-500 dark:text-gray-400">
|
|
|
+ Duration is not stored, so it can only be ordered within the {executions.length} rows
|
|
|
+ on this page - not across all {total} results.
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* 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
|
|
|
+ Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total}
|
|
|
+ {hasFilters ? ' matching executions' : ' executions'}
|
|
|
</div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
<button
|
|
|
- onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
|
+ onClick={() => updateParams({ page: page > 2 ? String(page - 1) : null }, true)}
|
|
|
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"
|
|
|
>
|
|
|
@@ -279,7 +611,7 @@ export default function ExecutionsPage() {
|
|
|
Page {page} of {totalPages || 1}
|
|
|
</span>
|
|
|
<button
|
|
|
- onClick={() => setPage(p => p + 1)}
|
|
|
+ onClick={() => updateParams({ page: String(page + 1) }, true)}
|
|
|
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"
|
|
|
>
|