| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627 |
- 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, Search, X } 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'
- }
- }
- // 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 | null, finishedAt: number | null, now: number): string {
- if (!startedAt) return '-'
- const end = 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
- // 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 [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
- // 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, 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()
- // 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 => 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 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':
- 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" />
- 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:
- 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'
- case 'waiting':
- return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
- default:
- return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
- }
- }
- 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-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={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">
- {/* 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>
- ) : (
- <>
- <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>
- <th className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-300">
- Actions
- </th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
- {executions.map(execution => {
- const startedAt = toMillis(execution.startedAt)
- const finishedAt = toMillis(execution.finishedAt)
- return (
- <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>
- <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"
- 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"
- 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(startedAt, 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>
- </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}
- {hasFilters ? ' matching executions' : ' executions'}
- </div>
- <div className="flex items-center gap-2">
- <button
- 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"
- >
- <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={() => 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"
- >
- Next
- <ChevronRight className="w-4 h-4" />
- </button>
- </div>
- </div>
- </>
- )}
- </div>
- )
- }
|