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.
- stemtrace/__init__.py +173 -0
- stemtrace/core/__init__.py +15 -0
- stemtrace/core/events.py +67 -0
- stemtrace/core/exceptions.py +25 -0
- stemtrace/core/graph.py +348 -0
- stemtrace/core/ports.py +42 -0
- stemtrace/library/__init__.py +1 -0
- stemtrace/library/bootsteps.py +169 -0
- stemtrace/library/config.py +55 -0
- stemtrace/library/scrubbing.py +195 -0
- stemtrace/library/signals.py +434 -0
- stemtrace/library/transports/__init__.py +36 -0
- stemtrace/library/transports/memory.py +33 -0
- stemtrace/library/transports/redis.py +92 -0
- stemtrace/py.typed +0 -0
- stemtrace/server/__init__.py +24 -0
- stemtrace/server/__main__.py +136 -0
- stemtrace/server/api/__init__.py +29 -0
- stemtrace/server/api/routes.py +270 -0
- stemtrace/server/api/schemas.py +136 -0
- stemtrace/server/api/websocket.py +28 -0
- stemtrace/server/consumer.py +133 -0
- stemtrace/server/fastapi/__init__.py +13 -0
- stemtrace/server/fastapi/auth.py +77 -0
- stemtrace/server/fastapi/extension.py +121 -0
- stemtrace/server/fastapi/router.py +42 -0
- stemtrace/server/store.py +260 -0
- stemtrace/server/ui/__init__.py +5 -0
- stemtrace/server/ui/frontend/biome.json +58 -0
- stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
- stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
- stemtrace/server/ui/frontend/dist/index.html +17 -0
- stemtrace/server/ui/frontend/index.html +16 -0
- stemtrace/server/ui/frontend/package-lock.json +4049 -0
- stemtrace/server/ui/frontend/package.json +46 -0
- stemtrace/server/ui/frontend/playwright.config.ts +40 -0
- stemtrace/server/ui/frontend/postcss.config.js +5 -0
- stemtrace/server/ui/frontend/src/api/client.ts +173 -0
- stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
- stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
- stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
- stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
- stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
- stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
- stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
- stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
- stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
- stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
- stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
- stemtrace/server/ui/frontend/src/index.css +104 -0
- stemtrace/server/ui/frontend/src/main.tsx +44 -0
- stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
- stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
- stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
- stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
- stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
- stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
- stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
- stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
- stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
- stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
- stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
- stemtrace/server/ui/frontend/tsconfig.json +32 -0
- stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
- stemtrace/server/ui/frontend/vite.config.ts +30 -0
- stemtrace/server/ui/static.py +56 -0
- stemtrace/server/websocket.py +99 -0
- stemtrace-0.1.0.dist-info/METADATA +428 -0
- stemtrace-0.1.0.dist-info/RECORD +72 -0
- stemtrace-0.1.0.dist-info/WHEEL +4 -0
- stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|