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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task list component with infinite scroll.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Link } from '@tanstack/react-router'
|
|
6
|
+
import { useTasksInfinite } from '@/api/queries'
|
|
7
|
+
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
|
8
|
+
import { TaskStateBadge } from './TaskStateBadge'
|
|
9
|
+
|
|
10
|
+
interface TaskListProps {
|
|
11
|
+
filters: {
|
|
12
|
+
state: string | undefined
|
|
13
|
+
name: string
|
|
14
|
+
from_date?: string
|
|
15
|
+
to_date?: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TaskList({ filters }: TaskListProps) {
|
|
20
|
+
const { data, isLoading, error, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
|
21
|
+
useTasksInfinite({
|
|
22
|
+
state: filters.state,
|
|
23
|
+
name: filters.name || undefined,
|
|
24
|
+
from_date: filters.from_date,
|
|
25
|
+
to_date: filters.to_date,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const { lastElementRef } = useInfiniteScroll({
|
|
29
|
+
hasNextPage,
|
|
30
|
+
isFetchingNextPage,
|
|
31
|
+
fetchNextPage,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (isLoading) {
|
|
35
|
+
return <TaskListSkeleton />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (error) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="text-center py-12 text-red-400">
|
|
41
|
+
Failed to load tasks. Is the server running?
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allTasks = data?.pages.flatMap((page) => page.tasks) ?? []
|
|
47
|
+
const total = data?.pages[0]?.total ?? 0
|
|
48
|
+
|
|
49
|
+
if (!allTasks.length) {
|
|
50
|
+
return <EmptyState />
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
{allTasks.map((task, index) => {
|
|
56
|
+
const isLast = index === allTasks.length - 1
|
|
57
|
+
return (
|
|
58
|
+
<Link
|
|
59
|
+
key={task.task_id}
|
|
60
|
+
ref={isLast ? lastElementRef : undefined}
|
|
61
|
+
to="/tasks/$taskId"
|
|
62
|
+
params={{ taskId: task.task_id }}
|
|
63
|
+
className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800 hover:border-slate-700 transition-colors"
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-center gap-4 min-w-0">
|
|
66
|
+
<TaskStateBadge state={task.state} />
|
|
67
|
+
<div className="min-w-0">
|
|
68
|
+
<p className="text-slate-200 truncate">{task.name}</p>
|
|
69
|
+
<p className="text-xs text-slate-500 font-mono">{task.task_id.slice(0, 8)}...</p>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="flex items-center gap-6 text-sm text-slate-400">
|
|
74
|
+
{task.duration_ms !== null && <span className="font-mono">{task.duration_ms}ms</span>}
|
|
75
|
+
{task.children.length > 0 && (
|
|
76
|
+
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded">
|
|
77
|
+
{task.children.length} children
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
<span className="text-xs">{formatRelativeTime(task.last_updated)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
</Link>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
|
|
86
|
+
{/* Loading indicator */}
|
|
87
|
+
{isFetchingNextPage && <LoadingMore />}
|
|
88
|
+
|
|
89
|
+
{/* Pagination info */}
|
|
90
|
+
<div className="text-center text-sm text-slate-500 pt-4">
|
|
91
|
+
Showing {allTasks.length} of {total} tasks
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatRelativeTime(timestamp: string | null): string {
|
|
98
|
+
if (!timestamp) return ''
|
|
99
|
+
const date = new Date(timestamp)
|
|
100
|
+
const now = new Date()
|
|
101
|
+
const diff = now.getTime() - date.getTime()
|
|
102
|
+
|
|
103
|
+
if (diff < 60000) return 'just now'
|
|
104
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
105
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
|
106
|
+
return date.toLocaleDateString()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function EmptyState() {
|
|
110
|
+
return (
|
|
111
|
+
<div className="text-center py-16 bg-slate-900/50 rounded-xl border border-slate-800">
|
|
112
|
+
<div className="text-4xl mb-4">📋</div>
|
|
113
|
+
<h3 className="text-lg font-medium text-slate-200">No tasks found</h3>
|
|
114
|
+
<p className="text-sm text-slate-500 mt-1">
|
|
115
|
+
Try adjusting your filters or run some Celery tasks
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function LoadingMore() {
|
|
122
|
+
return (
|
|
123
|
+
<div className="flex justify-center py-4">
|
|
124
|
+
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
125
|
+
<svg
|
|
126
|
+
className="animate-spin h-4 w-4"
|
|
127
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
128
|
+
fill="none"
|
|
129
|
+
viewBox="0 0 24 24"
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
>
|
|
132
|
+
<circle
|
|
133
|
+
className="opacity-25"
|
|
134
|
+
cx="12"
|
|
135
|
+
cy="12"
|
|
136
|
+
r="10"
|
|
137
|
+
stroke="currentColor"
|
|
138
|
+
strokeWidth="4"
|
|
139
|
+
/>
|
|
140
|
+
<path
|
|
141
|
+
className="opacity-75"
|
|
142
|
+
fill="currentColor"
|
|
143
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
144
|
+
/>
|
|
145
|
+
</svg>
|
|
146
|
+
Loading more...
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function TaskListSkeleton() {
|
|
153
|
+
return (
|
|
154
|
+
<div className="space-y-2 animate-pulse">
|
|
155
|
+
{[...Array(5)].map((_, i) => (
|
|
156
|
+
<div
|
|
157
|
+
key={i}
|
|
158
|
+
className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800"
|
|
159
|
+
>
|
|
160
|
+
<div className="flex items-center gap-4">
|
|
161
|
+
<div className="w-16 h-5 bg-slate-800 rounded" />
|
|
162
|
+
<div>
|
|
163
|
+
<div className="w-48 h-4 bg-slate-800 rounded" />
|
|
164
|
+
<div className="w-24 h-3 bg-slate-800 rounded mt-2" />
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="w-16 h-4 bg-slate-800 rounded" />
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Badge component for displaying task state.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { clsx } from 'clsx'
|
|
6
|
+
|
|
7
|
+
interface TaskStateBadgeProps {
|
|
8
|
+
state: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const stateClasses: Record<string, string> = {
|
|
13
|
+
PENDING: 'task-state-pending',
|
|
14
|
+
RECEIVED: 'task-state-received',
|
|
15
|
+
STARTED: 'task-state-started',
|
|
16
|
+
SUCCESS: 'task-state-success',
|
|
17
|
+
FAILURE: 'task-state-failure',
|
|
18
|
+
RETRY: 'task-state-retry',
|
|
19
|
+
REVOKED: 'task-state-revoked',
|
|
20
|
+
REJECTED: 'task-state-rejected',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TaskStateBadge({ state, className }: TaskStateBadgeProps) {
|
|
24
|
+
const stateClass = stateClasses[state] ?? 'task-state-pending'
|
|
25
|
+
|
|
26
|
+
return <span className={clsx('task-state-badge', stateClass, className)}>{state}</span>
|
|
27
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { TaskNode } from '@/api/client'
|
|
4
|
+
import { useTasks } from '@/api/queries'
|
|
5
|
+
import { formatDuration, stateColors, TERMINAL_STATES, TimelineBar } from './TimelineBar'
|
|
6
|
+
|
|
7
|
+
interface TaskTimelineProps {
|
|
8
|
+
filters: {
|
|
9
|
+
state: string | undefined
|
|
10
|
+
name: string
|
|
11
|
+
from_date?: string
|
|
12
|
+
to_date?: string
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TaskTimeline({ filters }: TaskTimelineProps) {
|
|
17
|
+
const { data, isLoading, error } = useTasks({
|
|
18
|
+
state: filters.state,
|
|
19
|
+
name: filters.name || undefined,
|
|
20
|
+
from_date: filters.from_date,
|
|
21
|
+
to_date: filters.to_date,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (isLoading) {
|
|
25
|
+
return <TimelineSkeleton />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (error) {
|
|
29
|
+
return <div className="text-center py-12 text-red-400">Failed to load timeline</div>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!data?.tasks.length) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="text-center py-16 bg-slate-900/50 rounded-xl border border-slate-800">
|
|
35
|
+
<div className="text-4xl mb-4">📊</div>
|
|
36
|
+
<h3 className="text-lg font-medium text-slate-200">No tasks to display</h3>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tasks = data.tasks.filter((t) => t.first_seen && t.last_updated)
|
|
42
|
+
if (tasks.length === 0) {
|
|
43
|
+
return <div className="text-center py-12 text-slate-400">No timing data available</div>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const times = tasks.flatMap((t) => [
|
|
47
|
+
new Date(t.first_seen!).getTime(),
|
|
48
|
+
new Date(t.last_updated!).getTime(),
|
|
49
|
+
])
|
|
50
|
+
const minTime = Math.min(...times)
|
|
51
|
+
const maxTime = Math.max(...times)
|
|
52
|
+
const range = maxTime - minTime || 1
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6 overflow-x-auto">
|
|
56
|
+
<div className="flex justify-between text-xs text-slate-500 mb-4 pl-48">
|
|
57
|
+
<span>{formatTime(new Date(minTime))}</span>
|
|
58
|
+
<span>{formatTime(new Date(minTime + range / 2))}</span>
|
|
59
|
+
<span>{formatTime(new Date(maxTime))}</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div className="space-y-2">
|
|
63
|
+
{tasks.map((task) => (
|
|
64
|
+
<TaskTimelineRow key={task.task_id} task={task} minTime={minTime} range={range} />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="flex flex-wrap gap-4 mt-6 pt-4 border-t border-slate-800 text-xs text-slate-400">
|
|
69
|
+
{Object.entries(stateColors).map(([state, color]) => (
|
|
70
|
+
<div key={state} className="flex items-center gap-1.5">
|
|
71
|
+
<div
|
|
72
|
+
className={clsx('h-3 rounded', color, TERMINAL_STATES.has(state) ? 'w-2' : 'w-3')}
|
|
73
|
+
/>
|
|
74
|
+
<span>{state}</span>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface TaskTimelineRowProps {
|
|
83
|
+
task: TaskNode
|
|
84
|
+
minTime: number
|
|
85
|
+
range: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function TaskTimelineRow({ task, minTime, range }: TaskTimelineRowProps) {
|
|
89
|
+
const taskStart = task.first_seen ? new Date(task.first_seen).getTime() : minTime
|
|
90
|
+
const taskStartPct = ((taskStart - minTime) / range) * 100
|
|
91
|
+
const wouldOverflow = taskStartPct > 85
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-center gap-4 h-8 group">
|
|
95
|
+
<Link
|
|
96
|
+
to="/tasks/$taskId"
|
|
97
|
+
params={{ taskId: task.task_id }}
|
|
98
|
+
className="w-44 truncate text-sm text-slate-300 hover:text-emerald-400 transition-colors"
|
|
99
|
+
title={task.name}
|
|
100
|
+
>
|
|
101
|
+
{task.name.split('.').pop()}
|
|
102
|
+
</Link>
|
|
103
|
+
|
|
104
|
+
<div className="flex-1 relative h-6">
|
|
105
|
+
<div className="absolute inset-0 bg-slate-800/50 rounded" />
|
|
106
|
+
<div
|
|
107
|
+
className="absolute h-full"
|
|
108
|
+
style={
|
|
109
|
+
wouldOverflow
|
|
110
|
+
? { right: 0, minWidth: '80px', maxWidth: '200px' }
|
|
111
|
+
: { left: `${taskStartPct}%`, minWidth: '80px', maxWidth: '200px' }
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
<TimelineBar events={task.events} height="sm" />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="w-20 text-right text-xs text-slate-500 font-mono">
|
|
119
|
+
{task.duration_ms !== null ? formatDuration(task.duration_ms) : '-'}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatTime(date: Date): string {
|
|
126
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function TimelineSkeleton() {
|
|
130
|
+
return (
|
|
131
|
+
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6 animate-pulse">
|
|
132
|
+
<div className="h-4 w-full bg-slate-800 rounded mb-4" />
|
|
133
|
+
<div className="space-y-2">
|
|
134
|
+
{[...Array(5)].map((_, i) => (
|
|
135
|
+
<div key={i} className="flex items-center gap-4 h-8">
|
|
136
|
+
<div className="w-44 h-4 bg-slate-800 rounded" />
|
|
137
|
+
<div className="flex-1 h-6 bg-slate-800 rounded" />
|
|
138
|
+
<div className="w-20 h-4 bg-slate-800 rounded" />
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import type { TaskEvent } from '../api/client'
|
|
4
|
+
|
|
5
|
+
const stateColors: Record<string, string> = {
|
|
6
|
+
PENDING: 'bg-slate-500',
|
|
7
|
+
RECEIVED: 'bg-slate-400',
|
|
8
|
+
STARTED: 'bg-blue-500',
|
|
9
|
+
SUCCESS: 'bg-emerald-500',
|
|
10
|
+
FAILURE: 'bg-red-500',
|
|
11
|
+
RETRY: 'bg-amber-500',
|
|
12
|
+
REVOKED: 'bg-purple-500',
|
|
13
|
+
REJECTED: 'bg-red-600',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TERMINAL_STATES = new Set(['SUCCESS', 'FAILURE', 'REVOKED', 'REJECTED'])
|
|
17
|
+
|
|
18
|
+
// State priority within a retry cycle: PENDING → RECEIVED → STARTED → RETRY → terminal
|
|
19
|
+
const STATE_PRIORITY: Record<string, number> = {
|
|
20
|
+
PENDING: 0,
|
|
21
|
+
RECEIVED: 1,
|
|
22
|
+
STARTED: 2,
|
|
23
|
+
RETRY: 3,
|
|
24
|
+
SUCCESS: 10,
|
|
25
|
+
FAILURE: 10,
|
|
26
|
+
REVOKED: 10,
|
|
27
|
+
REJECTED: 10,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const heightClasses = { sm: 'h-6', md: 'h-8', lg: 'h-10' }
|
|
31
|
+
|
|
32
|
+
const terminalIcons: Record<string, string> = {
|
|
33
|
+
SUCCESS: '✓',
|
|
34
|
+
FAILURE: '×',
|
|
35
|
+
REVOKED: '⊘',
|
|
36
|
+
REJECTED: '⊘',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TimelineSegment {
|
|
40
|
+
state: string
|
|
41
|
+
duration: number
|
|
42
|
+
timestamp: Date
|
|
43
|
+
isTerminal: boolean
|
|
44
|
+
widthPct: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildSegments(events: TaskEvent[]): TimelineSegment[] {
|
|
48
|
+
if (!events || events.length === 0) return []
|
|
49
|
+
|
|
50
|
+
// Filter: only keep first PENDING and first RECEIVED (retry re-queues create duplicates)
|
|
51
|
+
let hasPending = false
|
|
52
|
+
let hasReceived = false
|
|
53
|
+
const filtered = events.filter((e) => {
|
|
54
|
+
if (e.state === 'PENDING') {
|
|
55
|
+
if (hasPending) return false
|
|
56
|
+
hasPending = true
|
|
57
|
+
}
|
|
58
|
+
if (e.state === 'RECEIVED') {
|
|
59
|
+
if (hasReceived) return false
|
|
60
|
+
hasReceived = true
|
|
61
|
+
}
|
|
62
|
+
return true
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Sort by logical order: (retry_count, state_priority, timestamp)
|
|
66
|
+
// This ensures correct ordering regardless of timing issues
|
|
67
|
+
const sorted = filtered.sort((a, b) => {
|
|
68
|
+
const retryA = a.retries ?? 0
|
|
69
|
+
const retryB = b.retries ?? 0
|
|
70
|
+
|
|
71
|
+
// 1. Sort by retry count first
|
|
72
|
+
if (retryA !== retryB) return retryA - retryB
|
|
73
|
+
|
|
74
|
+
// 2. Within same retry, sort by state lifecycle
|
|
75
|
+
const priorityA = STATE_PRIORITY[a.state] ?? 5
|
|
76
|
+
const priorityB = STATE_PRIORITY[b.state] ?? 5
|
|
77
|
+
if (priorityA !== priorityB) return priorityA - priorityB
|
|
78
|
+
|
|
79
|
+
// 3. Same retry and state: use timestamp as tiebreaker
|
|
80
|
+
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const minTime = new Date(sorted[0].timestamp).getTime()
|
|
84
|
+
const maxTime = new Date(sorted[sorted.length - 1].timestamp).getTime()
|
|
85
|
+
const range = maxTime - minTime || 1
|
|
86
|
+
|
|
87
|
+
const segments: TimelineSegment[] = []
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
90
|
+
const event = sorted[i]
|
|
91
|
+
const eventTime = new Date(event.timestamp).getTime()
|
|
92
|
+
const isTerminal = TERMINAL_STATES.has(event.state)
|
|
93
|
+
const nextEvent = sorted[i + 1]
|
|
94
|
+
const endTime = nextEvent ? new Date(nextEvent.timestamp).getTime() : eventTime
|
|
95
|
+
|
|
96
|
+
const duration = endTime - eventTime
|
|
97
|
+
const widthPct = range > 0 ? (duration / range) * 100 : 100 / sorted.length
|
|
98
|
+
|
|
99
|
+
segments.push({
|
|
100
|
+
state: event.state,
|
|
101
|
+
duration,
|
|
102
|
+
timestamp: new Date(event.timestamp),
|
|
103
|
+
isTerminal,
|
|
104
|
+
widthPct,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return segments
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatDuration(ms: number): string {
|
|
112
|
+
if (ms < 1000) return `${ms}ms`
|
|
113
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
114
|
+
return `${(ms / 60000).toFixed(1)}m`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatTime(date: Date): string {
|
|
118
|
+
return date.toLocaleTimeString()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface TimelineBarProps {
|
|
122
|
+
events: TaskEvent[]
|
|
123
|
+
height?: 'sm' | 'md' | 'lg'
|
|
124
|
+
showLabels?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function TimelineBar({ events, height = 'sm', showLabels = false }: TimelineBarProps) {
|
|
128
|
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
129
|
+
const [mouseX, setMouseX] = useState(0)
|
|
130
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
131
|
+
|
|
132
|
+
const segments = buildSegments(events)
|
|
133
|
+
|
|
134
|
+
if (segments.length === 0) {
|
|
135
|
+
return <div className={heightClasses[height]} />
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const totalDuration = segments.reduce((sum, s) => sum + s.duration, 0) || 1
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
ref={containerRef}
|
|
143
|
+
className={clsx('relative overflow-visible flex', heightClasses[height])}
|
|
144
|
+
onMouseMove={(e) => {
|
|
145
|
+
if (containerRef.current) {
|
|
146
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
147
|
+
setMouseX(e.clientX - rect.left)
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{segments.map((segment, i) => {
|
|
152
|
+
const durationRatio = totalDuration > 0 ? segment.duration / totalDuration : 0
|
|
153
|
+
const isHovered = hoveredIndex === i
|
|
154
|
+
const isFirst = i === 0
|
|
155
|
+
const isLast = i === segments.length - 1
|
|
156
|
+
|
|
157
|
+
// Terminal states get fixed width, others grow proportionally
|
|
158
|
+
const flexStyle = segment.isTerminal
|
|
159
|
+
? '0 0 20px'
|
|
160
|
+
: `${Math.max(1, durationRatio * 100)} 1 ${Math.max(4, durationRatio * 100)}%`
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
key={i}
|
|
165
|
+
role="presentation"
|
|
166
|
+
className={clsx(
|
|
167
|
+
'h-full flex items-center justify-center cursor-pointer',
|
|
168
|
+
'transition-[filter,transform] duration-75',
|
|
169
|
+
stateColors[segment.state] ?? 'bg-gray-500',
|
|
170
|
+
i > 0 && 'border-l border-slate-900/30',
|
|
171
|
+
isHovered && 'brightness-125 z-10',
|
|
172
|
+
isFirst && 'rounded-l',
|
|
173
|
+
isLast && 'rounded-r',
|
|
174
|
+
)}
|
|
175
|
+
style={{
|
|
176
|
+
flex: flexStyle,
|
|
177
|
+
minWidth: segment.isTerminal ? '20px' : '6px',
|
|
178
|
+
maxWidth: segment.isTerminal ? '20px' : undefined,
|
|
179
|
+
}}
|
|
180
|
+
onMouseEnter={() => setHoveredIndex(i)}
|
|
181
|
+
onMouseLeave={() => setHoveredIndex(null)}
|
|
182
|
+
>
|
|
183
|
+
{showLabels && (
|
|
184
|
+
<span className="text-xs font-semibold text-white/90">
|
|
185
|
+
{segment.isTerminal
|
|
186
|
+
? (terminalIcons[segment.state] ?? '⊘')
|
|
187
|
+
: durationRatio > 0.15
|
|
188
|
+
? segment.state
|
|
189
|
+
: segment.state[0]}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
{!showLabels && segment.isTerminal && (
|
|
193
|
+
<span className="text-xs font-semibold text-white/90">
|
|
194
|
+
{terminalIcons[segment.state] ?? '⊘'}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
|
|
201
|
+
{hoveredIndex !== null && segments[hoveredIndex] && (
|
|
202
|
+
<div
|
|
203
|
+
className="absolute -top-12 bg-slate-950 border border-slate-700 text-white text-xs px-3 py-2 rounded-lg shadow-xl whitespace-nowrap z-20 pointer-events-none"
|
|
204
|
+
style={{ left: mouseX, transform: 'translateX(-50%)' }}
|
|
205
|
+
>
|
|
206
|
+
<div className="flex items-center gap-2">
|
|
207
|
+
<div
|
|
208
|
+
className={clsx('w-2 h-2 rounded-sm', stateColors[segments[hoveredIndex].state])}
|
|
209
|
+
/>
|
|
210
|
+
<span className="font-medium">{segments[hoveredIndex].state}</span>
|
|
211
|
+
<span className="text-slate-400">•</span>
|
|
212
|
+
<span className="font-mono">{formatDuration(segments[hoveredIndex].duration)}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="text-slate-400 mt-1 font-mono text-[10px]">
|
|
215
|
+
{formatTime(segments[hoveredIndex].timestamp)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { stateColors, TERMINAL_STATES, buildSegments, formatDuration, formatTime }
|
|
224
|
+
export type { TimelineSegment }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple tooltip component with instant hover display.
|
|
3
|
+
* Supports different placements to avoid going out of bounds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
|
|
9
|
+
|
|
10
|
+
interface TooltipProps {
|
|
11
|
+
content: string
|
|
12
|
+
placement?: TooltipPlacement
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const placementStyles: Record<TooltipPlacement, { container: string; arrow: string }> = {
|
|
17
|
+
top: {
|
|
18
|
+
container: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
|
19
|
+
arrow:
|
|
20
|
+
'top-full left-1/2 -translate-x-1/2 border-t-slate-800 border-x-transparent border-b-transparent',
|
|
21
|
+
},
|
|
22
|
+
bottom: {
|
|
23
|
+
container: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
|
24
|
+
arrow:
|
|
25
|
+
'bottom-full left-1/2 -translate-x-1/2 border-b-slate-800 border-x-transparent border-t-transparent',
|
|
26
|
+
},
|
|
27
|
+
left: {
|
|
28
|
+
container: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
|
29
|
+
arrow:
|
|
30
|
+
'left-full top-1/2 -translate-y-1/2 border-l-slate-800 border-y-transparent border-r-transparent',
|
|
31
|
+
},
|
|
32
|
+
right: {
|
|
33
|
+
container: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
|
34
|
+
arrow:
|
|
35
|
+
'right-full top-1/2 -translate-y-1/2 border-r-slate-800 border-y-transparent border-l-transparent',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Tooltip({ content, placement = 'bottom', children }: TooltipProps) {
|
|
40
|
+
const styles = placementStyles[placement]
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="relative group">
|
|
44
|
+
{children}
|
|
45
|
+
<div
|
|
46
|
+
className={`absolute ${styles.container} px-2 py-1 text-xs text-slate-200 bg-slate-800 rounded shadow-lg whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-opacity duration-150 pointer-events-none z-50`}
|
|
47
|
+
>
|
|
48
|
+
{content}
|
|
49
|
+
{/* Arrow */}
|
|
50
|
+
<div className={`absolute ${styles.arrow} border-4`} />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|