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,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
+ }