stemtrace 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,119 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useState } from 'react'
3
+ import { Filters } from '@/components/Filters'
4
+ import { TaskList } from '@/components/TaskList'
5
+ import { TaskTimeline } from '@/components/TaskTimeline'
6
+
7
+ interface SearchParams {
8
+ state?: string
9
+ name?: string
10
+ from_date?: string
11
+ to_date?: string
12
+ view?: 'list' | 'timeline'
13
+ }
14
+
15
+ export const Route = createFileRoute('/')({
16
+ component: TasksPage,
17
+ validateSearch: (search: Record<string, unknown>): SearchParams => ({
18
+ state: typeof search.state === 'string' ? search.state : undefined,
19
+ name: typeof search.name === 'string' ? search.name : undefined,
20
+ from_date: typeof search.from_date === 'string' ? search.from_date : undefined,
21
+ to_date: typeof search.to_date === 'string' ? search.to_date : undefined,
22
+ view: search.view === 'list' || search.view === 'timeline' ? search.view : undefined,
23
+ }),
24
+ })
25
+
26
+ type ViewMode = 'list' | 'timeline'
27
+
28
+ function TasksPage() {
29
+ const navigate = useNavigate()
30
+ const { state, name, from_date, to_date, view } = Route.useSearch()
31
+
32
+ const [viewMode, setViewMode] = useState<ViewMode>(view ?? 'list')
33
+ const filters = {
34
+ state,
35
+ name: name ?? '',
36
+ from_date,
37
+ to_date,
38
+ }
39
+
40
+ const setFilters = (newFilters: {
41
+ state?: string
42
+ name: string
43
+ from_date?: string
44
+ to_date?: string
45
+ }) => {
46
+ navigate({
47
+ to: '/',
48
+ search: {
49
+ state: newFilters.state,
50
+ name: newFilters.name || undefined,
51
+ from_date: newFilters.from_date,
52
+ to_date: newFilters.to_date,
53
+ view: viewMode,
54
+ },
55
+ replace: true,
56
+ })
57
+ }
58
+
59
+ const handleViewModeChange = (mode: ViewMode) => {
60
+ setViewMode(mode)
61
+ navigate({
62
+ to: '/',
63
+ search: {
64
+ state,
65
+ name: name || undefined,
66
+ from_date,
67
+ to_date,
68
+ view: mode,
69
+ },
70
+ replace: true,
71
+ })
72
+ }
73
+
74
+ return (
75
+ <div className="space-y-6">
76
+ {/* Page header */}
77
+ <div className="flex items-center justify-between">
78
+ <div>
79
+ <h1 className="text-2xl font-bold text-slate-100">Tasks</h1>
80
+ <p className="text-sm text-slate-500 mt-1">
81
+ Monitor your Celery task execution in real-time
82
+ </p>
83
+ </div>
84
+
85
+ {/* View toggle */}
86
+ <div className="flex items-center gap-2 bg-slate-800 rounded-lg p-1">
87
+ <button
88
+ type="button"
89
+ onClick={() => handleViewModeChange('list')}
90
+ className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
91
+ viewMode === 'list'
92
+ ? 'bg-slate-700 text-slate-100'
93
+ : 'text-slate-400 hover:text-slate-200'
94
+ }`}
95
+ >
96
+ List
97
+ </button>
98
+ <button
99
+ type="button"
100
+ onClick={() => handleViewModeChange('timeline')}
101
+ className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
102
+ viewMode === 'timeline'
103
+ ? 'bg-slate-700 text-slate-100'
104
+ : 'text-slate-400 hover:text-slate-200'
105
+ }`}
106
+ >
107
+ Timeline
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ {/* Filters */}
113
+ <Filters filters={filters} onFiltersChange={setFilters} />
114
+
115
+ {/* Content */}
116
+ {viewMode === 'list' ? <TaskList filters={filters} /> : <TaskTimeline filters={filters} />}
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,211 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useState } from 'react'
3
+ import { useTaskRegistry } from '@/api/queries'
4
+
5
+ export const Route = createFileRoute('/registry')({
6
+ component: RegistryPage,
7
+ })
8
+
9
+ function RegistryPage() {
10
+ const [searchQuery, setSearchQuery] = useState('')
11
+ const { data, isLoading, error } = useTaskRegistry(searchQuery || undefined)
12
+
13
+ return (
14
+ <div className="space-y-6">
15
+ {/* Header */}
16
+ <div className="flex items-center justify-between">
17
+ <div>
18
+ <h1 className="text-2xl font-bold text-slate-100">Task Registry</h1>
19
+ <p className="text-sm text-slate-400 mt-1">
20
+ All discovered task definitions ({data?.total ?? 0} tasks)
21
+ </p>
22
+ </div>
23
+ </div>
24
+
25
+ {/* Search */}
26
+ <div className="relative">
27
+ <input
28
+ type="text"
29
+ placeholder="Search tasks..."
30
+ value={searchQuery}
31
+ onChange={(e) => setSearchQuery(e.target.value)}
32
+ className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500"
33
+ />
34
+ <svg
35
+ aria-hidden="true"
36
+ className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ viewBox="0 0 24 24"
40
+ >
41
+ <path
42
+ strokeLinecap="round"
43
+ strokeLinejoin="round"
44
+ strokeWidth={2}
45
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
46
+ />
47
+ </svg>
48
+ </div>
49
+
50
+ {/* Loading state */}
51
+ {isLoading && (
52
+ <div className="space-y-3">
53
+ {[...Array(5)].map((_, i) => (
54
+ <div
55
+ key={i}
56
+ className="bg-slate-900 rounded-xl border border-slate-800 p-4 animate-pulse"
57
+ >
58
+ <div className="h-5 w-64 bg-slate-800 rounded" />
59
+ <div className="h-4 w-48 bg-slate-800 rounded mt-2" />
60
+ </div>
61
+ ))}
62
+ </div>
63
+ )}
64
+
65
+ {/* Error state */}
66
+ {error && (
67
+ <div className="text-center py-12">
68
+ <p className="text-red-400">Failed to load task registry</p>
69
+ </div>
70
+ )}
71
+
72
+ {/* Task list */}
73
+ {data && data.tasks.length > 0 && (
74
+ <div className="space-y-3">
75
+ {data.tasks.map((task) => (
76
+ <TaskCard key={task.name} task={task} />
77
+ ))}
78
+ </div>
79
+ )}
80
+
81
+ {/* Empty state */}
82
+ {data && data.tasks.length === 0 && (
83
+ <div className="text-center py-12 bg-slate-900 rounded-xl border border-slate-800">
84
+ <svg
85
+ aria-hidden="true"
86
+ className="w-12 h-12 text-slate-600 mx-auto mb-4"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ viewBox="0 0 24 24"
90
+ >
91
+ <path
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ strokeWidth={1.5}
95
+ d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
96
+ />
97
+ </svg>
98
+ <p className="text-slate-400">
99
+ {searchQuery ? 'No tasks match your search' : 'No tasks discovered yet'}
100
+ </p>
101
+ <p className="text-sm text-slate-500 mt-1">
102
+ Run some Celery tasks to see them appear here
103
+ </p>
104
+ </div>
105
+ )}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ interface TaskCardProps {
111
+ task: {
112
+ name: string
113
+ signature: string | null
114
+ docstring: string | null
115
+ module: string | null
116
+ bound: boolean
117
+ }
118
+ }
119
+
120
+ function TaskCard({ task }: TaskCardProps) {
121
+ const [expanded, setExpanded] = useState(false)
122
+ const shortName = task.name.split('.').pop() || task.name
123
+
124
+ return (
125
+ <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
126
+ {/* Header */}
127
+ <button
128
+ type="button"
129
+ onClick={() => setExpanded(!expanded)}
130
+ className="w-full p-4 flex items-center justify-between text-left hover:bg-slate-800/50 transition-colors"
131
+ >
132
+ <div className="flex items-center gap-3">
133
+ <div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center">
134
+ <svg
135
+ aria-hidden="true"
136
+ className="w-4 h-4 text-emerald-400"
137
+ fill="none"
138
+ stroke="currentColor"
139
+ viewBox="0 0 24 24"
140
+ >
141
+ <path
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ strokeWidth={2}
145
+ d="M13 10V3L4 14h7v7l9-11h-7z"
146
+ />
147
+ </svg>
148
+ </div>
149
+ <div>
150
+ <h3 className="font-medium text-slate-100">{shortName}</h3>
151
+ <p className="text-sm text-slate-500 font-mono">{task.module || 'unknown'}</p>
152
+ </div>
153
+ </div>
154
+ <div className="flex items-center gap-3">
155
+ {task.bound && (
156
+ <span className="text-xs px-2 py-1 rounded bg-slate-800 text-slate-400">bound</span>
157
+ )}
158
+ <svg
159
+ aria-hidden="true"
160
+ className={`w-5 h-5 text-slate-500 transition-transform ${expanded ? 'rotate-180' : ''}`}
161
+ fill="none"
162
+ stroke="currentColor"
163
+ viewBox="0 0 24 24"
164
+ >
165
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
166
+ </svg>
167
+ </div>
168
+ </button>
169
+
170
+ {/* Expanded content */}
171
+ {expanded && (
172
+ <div className="border-t border-slate-800 p-4 space-y-4">
173
+ {/* Full name */}
174
+ <div>
175
+ <dt className="text-xs text-slate-500 mb-1">Full Name</dt>
176
+ <dd className="font-mono text-sm text-slate-300">{task.name}</dd>
177
+ </div>
178
+
179
+ {/* Signature */}
180
+ {task.signature && (
181
+ <div>
182
+ <dt className="text-xs text-slate-500 mb-1">Signature</dt>
183
+ <dd className="font-mono text-sm text-emerald-400 bg-slate-950 rounded-lg px-3 py-2">
184
+ {task.signature}
185
+ </dd>
186
+ </div>
187
+ )}
188
+
189
+ {/* Docstring */}
190
+ {task.docstring && (
191
+ <div>
192
+ <dt className="text-xs text-slate-500 mb-1">Documentation</dt>
193
+ <dd className="text-sm text-slate-300 whitespace-pre-wrap">{task.docstring}</dd>
194
+ </div>
195
+ )}
196
+
197
+ {/* Actions */}
198
+ <div className="flex gap-2 pt-2">
199
+ <Link
200
+ to="/"
201
+ search={{ name: shortName }}
202
+ className="text-xs px-3 py-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 transition-colors"
203
+ >
204
+ View runs →
205
+ </Link>
206
+ </div>
207
+ </div>
208
+ )}
209
+ </div>
210
+ )
211
+ }
@@ -0,0 +1,313 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useMemo, useState } from 'react'
3
+ import type { TaskEvent } from '@/api/client'
4
+ import { useTask } from '@/api/queries'
5
+ import { TaskStateBadge } from '@/components/TaskStateBadge'
6
+ import { buildSegments, formatDuration, stateColors, TimelineBar } from '@/components/TimelineBar'
7
+
8
+ export const Route = createFileRoute('/tasks/$taskId')({
9
+ component: TaskDetailPage,
10
+ })
11
+
12
+ function TaskDetailPage() {
13
+ const { taskId } = Route.useParams()
14
+ const { data, isLoading, error } = useTask(taskId)
15
+
16
+ if (isLoading) {
17
+ return <TaskDetailSkeleton />
18
+ }
19
+
20
+ if (error || !data) {
21
+ return (
22
+ <div className="text-center py-12">
23
+ <p className="text-red-400">Failed to load task</p>
24
+ <Link to="/" className="text-blue-400 hover:underline mt-2 inline-block">
25
+ Back to tasks
26
+ </Link>
27
+ </div>
28
+ )
29
+ }
30
+
31
+ const { task, children } = data
32
+ const startedEvent = task.events.find((e) => e.state === 'STARTED')
33
+ const hasParams = startedEvent?.args || startedEvent?.kwargs
34
+ const successEvent = task.events.find((e) => e.state === 'SUCCESS')
35
+ const hasResult = successEvent?.result !== null && successEvent?.result !== undefined
36
+
37
+ return (
38
+ <div className="space-y-6">
39
+ {/* Breadcrumb */}
40
+ <nav className="flex items-center gap-2 text-sm">
41
+ <Link to="/" className="text-slate-400 hover:text-slate-200">
42
+ Tasks
43
+ </Link>
44
+ <span className="text-slate-600">/</span>
45
+ <span className="text-slate-200 font-mono">{taskId.slice(0, 8)}</span>
46
+ </nav>
47
+
48
+ {/* Task header */}
49
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
50
+ <div className="flex items-start justify-between">
51
+ <div>
52
+ <h1 className="text-xl font-semibold text-slate-100">{task.name}</h1>
53
+ <p className="text-sm text-slate-500 font-mono mt-1">{task.task_id}</p>
54
+ </div>
55
+ <TaskStateBadge state={task.state} />
56
+ </div>
57
+
58
+ <dl className="grid grid-cols-4 gap-4 mt-6">
59
+ <InfoCard label="Duration" value={formatDuration(task.duration_ms ?? 0)} />
60
+ <InfoCard label="First seen" value={formatTime(task.first_seen)} />
61
+ <InfoCard label="Last update" value={formatTime(task.last_updated)} />
62
+ <InfoCard label="Children" value={String(children.length)} />
63
+ </dl>
64
+ </div>
65
+
66
+ {/* Parameters */}
67
+ {hasParams && (
68
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
69
+ <h2 className="text-lg font-semibold text-slate-100 mb-4">Parameters</h2>
70
+ {startedEvent?.args && (
71
+ <div className="mb-4">
72
+ <span className="text-xs text-slate-500">args</span>
73
+ <div className="bg-slate-950 rounded-lg p-3 mt-1 font-mono text-sm text-slate-300 overflow-x-auto">
74
+ <pre>{JSON.stringify(startedEvent.args, null, 2)}</pre>
75
+ </div>
76
+ </div>
77
+ )}
78
+ {startedEvent?.kwargs && Object.keys(startedEvent.kwargs).length > 0 && (
79
+ <div>
80
+ <span className="text-xs text-slate-500">kwargs</span>
81
+ <div className="bg-slate-950 rounded-lg p-3 mt-1 font-mono text-sm text-slate-300 overflow-x-auto">
82
+ <pre>{JSON.stringify(startedEvent.kwargs, null, 2)}</pre>
83
+ </div>
84
+ </div>
85
+ )}
86
+ </div>
87
+ )}
88
+
89
+ {/* Result */}
90
+ {hasResult && (
91
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
92
+ <h2 className="text-lg font-semibold text-slate-100 mb-4">Result</h2>
93
+ <div className="bg-slate-950 rounded-lg p-3 font-mono text-sm text-emerald-400 overflow-x-auto">
94
+ <pre>{JSON.stringify(successEvent?.result, null, 2)}</pre>
95
+ </div>
96
+ </div>
97
+ )}
98
+
99
+ {/* Visual Timeline */}
100
+ {task.events.length > 1 && (
101
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
102
+ <h2 className="text-lg font-semibold text-slate-100 mb-4">Execution Timeline</h2>
103
+ <TaskExecutionTimeline events={task.events} />
104
+ </div>
105
+ )}
106
+
107
+ {/* Event timeline */}
108
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
109
+ <h2 className="text-lg font-semibold text-slate-100 mb-4">Event History</h2>
110
+ <div className="space-y-2">
111
+ {sortEventsByTime(task.events).map((event, index) => (
112
+ <EventRow key={index} event={event} />
113
+ ))}
114
+ </div>
115
+ </div>
116
+
117
+ {/* Children */}
118
+ {children.length > 0 && (
119
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
120
+ <h2 className="text-lg font-semibold text-slate-100 mb-4">Child Tasks</h2>
121
+ <div className="space-y-2">
122
+ {children.map((child) => (
123
+ <Link
124
+ key={child.task_id}
125
+ to="/tasks/$taskId"
126
+ params={{ taskId: child.task_id }}
127
+ className="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg hover:bg-slate-800 transition-colors"
128
+ >
129
+ <div className="flex-1 truncate">
130
+ <span className="text-slate-200">{child.name.split('.').pop()}</span>
131
+ <span className="text-slate-500 text-xs ml-2">{child.task_id.slice(0, 8)}</span>
132
+ </div>
133
+ <TaskStateBadge state={child.state} />
134
+ </Link>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ function EventRow({ event }: { event: TaskEvent }) {
144
+ const [expanded, setExpanded] = useState(false)
145
+ const hasTraceback = event.state === 'FAILURE' || event.state === 'RETRY'
146
+ const showError = hasTraceback && event.exception
147
+
148
+ const rowContent = (
149
+ <>
150
+ <span className="text-slate-500 font-mono text-xs">{formatTime(event.timestamp)}</span>
151
+ <TaskStateBadge state={event.state} />
152
+ {event.retries > 0 && <span className="text-xs text-slate-500">retry #{event.retries}</span>}
153
+ {showError && <span className="text-xs text-red-400 truncate flex-1">{event.exception}</span>}
154
+ {hasTraceback && event.traceback && (
155
+ <span className="text-xs text-slate-500 ml-auto">
156
+ {expanded ? '▲ collapse' : '▼ expand'}
157
+ </span>
158
+ )}
159
+ </>
160
+ )
161
+
162
+ return (
163
+ <div className="border-l-2 border-slate-700 pl-4 py-1">
164
+ {hasTraceback && event.traceback ? (
165
+ <button
166
+ type="button"
167
+ className="flex items-center gap-4 text-sm cursor-pointer w-full text-left hover:bg-slate-800/30 rounded -ml-1 pl-1 py-0.5 transition-colors"
168
+ onClick={() => setExpanded(!expanded)}
169
+ >
170
+ {rowContent}
171
+ </button>
172
+ ) : (
173
+ <div className="flex items-center gap-4 text-sm">{rowContent}</div>
174
+ )}
175
+
176
+ {expanded && event.traceback && (
177
+ <div className="mt-2 bg-slate-950 rounded-lg p-3 font-mono text-xs text-red-300 overflow-x-auto whitespace-pre">
178
+ {event.traceback}
179
+ </div>
180
+ )}
181
+ </div>
182
+ )
183
+ }
184
+
185
+ function InfoCard({ label, value }: { label: string; value: string }) {
186
+ return (
187
+ <div>
188
+ <dt className="text-xs text-slate-500">{label}</dt>
189
+ <dd className="text-sm text-slate-200 font-mono mt-1">{value}</dd>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ function formatTime(timestamp: string | null | undefined): string {
195
+ if (!timestamp) return '-'
196
+ return new Date(timestamp).toLocaleTimeString()
197
+ }
198
+
199
+ // State priority within a retry cycle: PENDING → RECEIVED → STARTED → RETRY → terminal
200
+ const STATE_PRIORITY: Record<string, number> = {
201
+ PENDING: 0,
202
+ RECEIVED: 1,
203
+ STARTED: 2,
204
+ RETRY: 3,
205
+ SUCCESS: 10,
206
+ FAILURE: 10,
207
+ REVOKED: 10,
208
+ REJECTED: 10,
209
+ }
210
+
211
+ function sortEventsByTime(events: TaskEvent[]): TaskEvent[] {
212
+ // Filter: only keep first PENDING and first RECEIVED (retry re-queues create duplicates)
213
+ let hasPending = false
214
+ let hasReceived = false
215
+ const filtered = events.filter((e) => {
216
+ if (e.state === 'PENDING') {
217
+ if (hasPending) return false
218
+ hasPending = true
219
+ }
220
+ if (e.state === 'RECEIVED') {
221
+ if (hasReceived) return false
222
+ hasReceived = true
223
+ }
224
+ return true
225
+ })
226
+
227
+ // Sort by logical order: (retry_count, state_priority, timestamp)
228
+ // This ensures correct ordering regardless of timing issues
229
+ return filtered.sort((a, b) => {
230
+ const retryA = a.retries ?? 0
231
+ const retryB = b.retries ?? 0
232
+
233
+ // 1. Sort by retry count first
234
+ if (retryA !== retryB) return retryA - retryB
235
+
236
+ // 2. Within same retry, sort by state lifecycle
237
+ const priorityA = STATE_PRIORITY[a.state] ?? 5
238
+ const priorityB = STATE_PRIORITY[b.state] ?? 5
239
+ if (priorityA !== priorityB) return priorityA - priorityB
240
+
241
+ // 3. Same retry and state: use timestamp as tiebreaker
242
+ return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
243
+ })
244
+ }
245
+
246
+ function TaskExecutionTimeline({ events }: { events: TaskEvent[] }) {
247
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
248
+ const segments = useMemo(() => buildSegments(events), [events])
249
+
250
+ if (segments.length === 0) return null
251
+
252
+ const sorted = [...events].sort(
253
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
254
+ )
255
+
256
+ return (
257
+ <div className="space-y-4">
258
+ <div className="bg-slate-800/50 rounded h-10">
259
+ <TimelineBar events={events} height="lg" showLabels />
260
+ </div>
261
+
262
+ {/* Time markers */}
263
+ <div className="flex justify-between text-xs text-slate-500">
264
+ <span>{formatTime(sorted[0].timestamp)}</span>
265
+ <span>{formatTime(sorted[sorted.length - 1].timestamp)}</span>
266
+ </div>
267
+
268
+ {/* Duration breakdown */}
269
+ <div className="flex flex-wrap gap-4 text-sm">
270
+ {segments.map((segment, i) => {
271
+ const isHovered = hoveredIndex === i
272
+ return (
273
+ <div
274
+ key={i}
275
+ role="presentation"
276
+ className={`flex items-center gap-2 transition-opacity cursor-default ${hoveredIndex !== null && !isHovered ? 'opacity-50' : ''}`}
277
+ onMouseEnter={() => setHoveredIndex(i)}
278
+ onMouseLeave={() => setHoveredIndex(null)}
279
+ >
280
+ <div
281
+ className={`rounded transition-transform ${stateColors[segment.state] ?? 'bg-gray-500'} ${segment.isTerminal ? 'w-2 h-4' : 'w-4 h-4'} ${isHovered ? 'scale-110' : ''}`}
282
+ />
283
+ <span className="text-slate-400">{segment.state}</span>
284
+ <span className="text-slate-200 font-mono font-medium">
285
+ {formatDuration(segment.duration)}
286
+ </span>
287
+ </div>
288
+ )
289
+ })}
290
+ </div>
291
+ </div>
292
+ )
293
+ }
294
+
295
+ function TaskDetailSkeleton() {
296
+ return (
297
+ <div className="space-y-6 animate-pulse">
298
+ <div className="h-4 w-32 bg-slate-800 rounded" />
299
+ <div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
300
+ <div className="h-6 w-64 bg-slate-800 rounded" />
301
+ <div className="h-4 w-48 bg-slate-800 rounded mt-2" />
302
+ <div className="grid grid-cols-4 gap-4 mt-6">
303
+ {[...Array(4)].map((_, i) => (
304
+ <div key={i}>
305
+ <div className="h-3 w-16 bg-slate-800 rounded" />
306
+ <div className="h-4 w-24 bg-slate-800 rounded mt-2" />
307
+ </div>
308
+ ))}
309
+ </div>
310
+ </div>
311
+ </div>
312
+ )
313
+ }
@@ -0,0 +1,38 @@
1
+ export function formatDuration(ms: number | null): string {
2
+ if (ms === null) return ''
3
+ if (ms < 1000) return `${ms}ms`
4
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
5
+ return `${(ms / 60000).toFixed(1)}m`
6
+ }
7
+
8
+ export function formatTime(timestamp: string | null): string {
9
+ if (!timestamp) return ''
10
+ const date = new Date(timestamp)
11
+ return date.toLocaleTimeString('en-US', {
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ second: '2-digit',
15
+ hour12: false,
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Format date as YYYY-MM-DD for clean URLs.
21
+ */
22
+ export function formatDateParam(date: Date | undefined): string | undefined {
23
+ if (!date) return undefined
24
+ const year = date.getFullYear()
25
+ const month = String(date.getMonth() + 1).padStart(2, '0')
26
+ const day = String(date.getDate()).padStart(2, '0')
27
+ return `${year}-${month}-${day}`
28
+ }
29
+
30
+ /**
31
+ * Parse YYYY-MM-DD string to Date (start of day in local timezone).
32
+ */
33
+ export function parseDateParam(str: string | undefined): Date | undefined {
34
+ if (!str) return undefined
35
+ // Parse as local date (not UTC)
36
+ const [year, month, day] = str.split('-').map(Number)
37
+ return new Date(year, month - 1, day)
38
+ }