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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter controls for task list with date range picker.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DateRange } from 'react-day-picker'
|
|
6
|
+
import { formatDateParam, parseDateParam } from '@/utils/format'
|
|
7
|
+
import { DateRangePicker } from './DateRangePicker'
|
|
8
|
+
|
|
9
|
+
interface FiltersProps {
|
|
10
|
+
filters: {
|
|
11
|
+
state: string | undefined
|
|
12
|
+
name: string
|
|
13
|
+
from_date?: string
|
|
14
|
+
to_date?: string
|
|
15
|
+
}
|
|
16
|
+
onFiltersChange: (filters: {
|
|
17
|
+
state: string | undefined
|
|
18
|
+
name: string
|
|
19
|
+
from_date?: string
|
|
20
|
+
to_date?: string
|
|
21
|
+
}) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STATES = [
|
|
25
|
+
{ value: undefined, label: 'All' },
|
|
26
|
+
{ value: 'PENDING', label: 'Pending' },
|
|
27
|
+
{ value: 'STARTED', label: 'Started' },
|
|
28
|
+
{ value: 'SUCCESS', label: 'Success' },
|
|
29
|
+
{ value: 'FAILURE', label: 'Failure' },
|
|
30
|
+
{ value: 'RETRY', label: 'Retry' },
|
|
31
|
+
{ value: 'REVOKED', label: 'Revoked' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function Filters({ filters, onFiltersChange }: FiltersProps) {
|
|
35
|
+
const dateRange: DateRange | undefined =
|
|
36
|
+
filters.from_date || filters.to_date
|
|
37
|
+
? {
|
|
38
|
+
from: parseDateParam(filters.from_date),
|
|
39
|
+
to: parseDateParam(filters.to_date),
|
|
40
|
+
}
|
|
41
|
+
: undefined
|
|
42
|
+
|
|
43
|
+
const handleDateChange = (range: DateRange | undefined) => {
|
|
44
|
+
onFiltersChange({
|
|
45
|
+
...filters,
|
|
46
|
+
from_date: formatDateParam(range?.from),
|
|
47
|
+
to_date: formatDateParam(range?.to),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hasActiveFilters = filters.state || filters.name || filters.from_date || filters.to_date
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-wrap items-center gap-4 p-4 bg-slate-900/50 rounded-lg border border-slate-800">
|
|
55
|
+
{/* State filter */}
|
|
56
|
+
<div className="flex items-center gap-2">
|
|
57
|
+
<span className="text-sm text-slate-400">State:</span>
|
|
58
|
+
<div className="flex gap-1">
|
|
59
|
+
{STATES.map((state) => (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
key={state.value ?? 'all'}
|
|
63
|
+
onClick={() => onFiltersChange({ ...filters, state: state.value })}
|
|
64
|
+
className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
65
|
+
filters.state === state.value
|
|
66
|
+
? 'bg-slate-700 text-slate-100'
|
|
67
|
+
: 'bg-slate-800 text-slate-400 hover:bg-slate-750 hover:text-slate-300'
|
|
68
|
+
}`}
|
|
69
|
+
>
|
|
70
|
+
{state.label}
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Date range picker */}
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<span className="text-sm text-slate-400">Date:</span>
|
|
79
|
+
<DateRangePicker value={dateRange} onChange={handleDateChange} />
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Name search */}
|
|
83
|
+
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
|
|
84
|
+
<label htmlFor="task-search" className="text-sm text-slate-400">
|
|
85
|
+
Search:
|
|
86
|
+
</label>
|
|
87
|
+
<input
|
|
88
|
+
id="task-search"
|
|
89
|
+
type="text"
|
|
90
|
+
value={filters.name}
|
|
91
|
+
onChange={(e) => onFiltersChange({ ...filters, name: e.target.value })}
|
|
92
|
+
placeholder="Filter by task name..."
|
|
93
|
+
className="flex-1 px-3 py-1.5 text-sm bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:border-slate-600"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Clear filters */}
|
|
98
|
+
{hasActiveFilters && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() =>
|
|
102
|
+
onFiltersChange({
|
|
103
|
+
state: undefined,
|
|
104
|
+
name: '',
|
|
105
|
+
from_date: undefined,
|
|
106
|
+
to_date: undefined,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
className="text-xs text-slate-500 hover:text-slate-300"
|
|
110
|
+
>
|
|
111
|
+
Clear
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task flow graph visualization using React Flow.
|
|
3
|
+
*
|
|
4
|
+
* GROUP and CHORD nodes are rendered as containers with child tasks inside.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Background,
|
|
9
|
+
BackgroundVariant,
|
|
10
|
+
Controls,
|
|
11
|
+
type Edge,
|
|
12
|
+
type Node,
|
|
13
|
+
Position,
|
|
14
|
+
ReactFlow,
|
|
15
|
+
useEdgesState,
|
|
16
|
+
useNodesState,
|
|
17
|
+
} from '@xyflow/react'
|
|
18
|
+
import { useMemo } from 'react'
|
|
19
|
+
import '@xyflow/react/dist/style.css'
|
|
20
|
+
import { Link } from '@tanstack/react-router'
|
|
21
|
+
import type { GraphNode, NodeType } from '@/api/client'
|
|
22
|
+
import { formatDuration, formatTime } from '@/utils/format'
|
|
23
|
+
|
|
24
|
+
interface TaskGraphProps {
|
|
25
|
+
nodes: Record<string, GraphNode>
|
|
26
|
+
rootId: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stateColors: Record<string, { bg: string; border: string }> = {
|
|
30
|
+
PENDING: { bg: '#374151', border: '#4B5563' },
|
|
31
|
+
RECEIVED: { bg: '#374151', border: '#4B5563' },
|
|
32
|
+
STARTED: { bg: '#1E40AF', border: '#3B82F6' },
|
|
33
|
+
SUCCESS: { bg: '#166534', border: '#22C55E' },
|
|
34
|
+
FAILURE: { bg: '#991B1B', border: '#EF4444' },
|
|
35
|
+
RETRY: { bg: '#92400E', border: '#F59E0B' },
|
|
36
|
+
REVOKED: { bg: '#5B21B6', border: '#8B5CF6' },
|
|
37
|
+
REJECTED: { bg: '#7F1D1D', border: '#DC2626' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const syntheticColors: Record<NodeType, { bg: string; border: string }> = {
|
|
41
|
+
TASK: { bg: '#0F172A', border: '#475569' },
|
|
42
|
+
GROUP: { bg: '#1E1B4B', border: '#6366F1' },
|
|
43
|
+
CHORD: { bg: '#312E81', border: '#818CF8' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TASK_NODE_MIN_WIDTH = 160
|
|
47
|
+
const TASK_NODE_HEIGHT = 100
|
|
48
|
+
const CONTAINER_PADDING = 16
|
|
49
|
+
const CONTAINER_HEADER = 40
|
|
50
|
+
const CHILD_SPACING = 12
|
|
51
|
+
const LEVEL_SPACING = 320
|
|
52
|
+
|
|
53
|
+
function buildGraphElements(
|
|
54
|
+
nodes: Record<string, GraphNode>,
|
|
55
|
+
rootId: string,
|
|
56
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
57
|
+
const flowNodes: Node[] = []
|
|
58
|
+
const flowEdges: Edge[] = []
|
|
59
|
+
const visited = new Set<string>()
|
|
60
|
+
const levels: Map<string, number> = new Map()
|
|
61
|
+
const positions: Map<number, number> = new Map()
|
|
62
|
+
|
|
63
|
+
// Track which nodes are inside containers (GROUP/CHORD header tasks)
|
|
64
|
+
// For CHORD, callbacks are NOT inside - they connect via edge
|
|
65
|
+
const containerChildren = new Set<string>()
|
|
66
|
+
const chordCallbacks = new Set<string>()
|
|
67
|
+
|
|
68
|
+
for (const node of Object.values(nodes)) {
|
|
69
|
+
const nodeType = node.node_type ?? 'TASK'
|
|
70
|
+
if (nodeType === 'GROUP') {
|
|
71
|
+
for (const childId of node.children) {
|
|
72
|
+
containerChildren.add(childId)
|
|
73
|
+
}
|
|
74
|
+
} else if (nodeType === 'CHORD') {
|
|
75
|
+
// For CHORD, header tasks go inside, callback stays outside
|
|
76
|
+
// The CHORD node's chord_id field contains the callback task_id
|
|
77
|
+
for (const childId of node.children) {
|
|
78
|
+
if (childId === node.chord_id) {
|
|
79
|
+
// This is the callback - render outside with edge
|
|
80
|
+
chordCallbacks.add(childId)
|
|
81
|
+
} else {
|
|
82
|
+
// Header task - render inside container
|
|
83
|
+
containerChildren.add(childId)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// BFS to assign levels (skip container children - they're positioned inside)
|
|
90
|
+
const queue: Array<{ id: string; level: number }> = [{ id: rootId, level: 0 }]
|
|
91
|
+
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const { id, level } = queue.shift()!
|
|
94
|
+
if (visited.has(id)) continue
|
|
95
|
+
visited.add(id)
|
|
96
|
+
|
|
97
|
+
const node = nodes[id]
|
|
98
|
+
if (!node) continue
|
|
99
|
+
|
|
100
|
+
levels.set(id, level)
|
|
101
|
+
|
|
102
|
+
const nodeType = node.node_type ?? 'TASK'
|
|
103
|
+
const isContainer = nodeType === 'GROUP' || nodeType === 'CHORD'
|
|
104
|
+
|
|
105
|
+
for (const childId of node.children) {
|
|
106
|
+
if (!visited.has(childId)) {
|
|
107
|
+
// Container children don't advance the level (they're inside)
|
|
108
|
+
if (isContainer && containerChildren.has(childId)) {
|
|
109
|
+
// Skip - will be positioned inside container
|
|
110
|
+
} else {
|
|
111
|
+
queue.push({ id: childId, level: level + 1 })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create flow nodes
|
|
118
|
+
visited.clear()
|
|
119
|
+
const queue2: string[] = [rootId]
|
|
120
|
+
|
|
121
|
+
while (queue2.length > 0) {
|
|
122
|
+
const id = queue2.shift()!
|
|
123
|
+
if (visited.has(id)) continue
|
|
124
|
+
visited.add(id)
|
|
125
|
+
|
|
126
|
+
const node = nodes[id]
|
|
127
|
+
if (!node) continue
|
|
128
|
+
|
|
129
|
+
// Skip if this node is inside a container (handled separately)
|
|
130
|
+
if (containerChildren.has(id)) continue
|
|
131
|
+
|
|
132
|
+
const level = levels.get(id) ?? 0
|
|
133
|
+
const nodeType = node.node_type ?? 'TASK'
|
|
134
|
+
const isContainer = nodeType === 'GROUP' || nodeType === 'CHORD'
|
|
135
|
+
const stateColor = stateColors[node.state] ?? stateColors.PENDING
|
|
136
|
+
const syntheticColor = syntheticColors[nodeType]
|
|
137
|
+
|
|
138
|
+
if (isContainer) {
|
|
139
|
+
// For CHORD, only header tasks go inside (not callbacks)
|
|
140
|
+
const headerChildren = node.children.filter((cid) => !chordCallbacks.has(cid))
|
|
141
|
+
const childCount = headerChildren.length
|
|
142
|
+
|
|
143
|
+
// Vertical layout: children stacked in a column
|
|
144
|
+
const containerWidth = TASK_NODE_MIN_WIDTH + CONTAINER_PADDING * 2
|
|
145
|
+
const containerHeight =
|
|
146
|
+
CONTAINER_HEADER +
|
|
147
|
+
CONTAINER_PADDING * 2 +
|
|
148
|
+
Math.max(1, childCount) * TASK_NODE_HEIGHT +
|
|
149
|
+
Math.max(0, childCount - 1) * CHILD_SPACING
|
|
150
|
+
|
|
151
|
+
const yPos = positions.get(level) ?? 0
|
|
152
|
+
positions.set(level, yPos + containerHeight + 40)
|
|
153
|
+
|
|
154
|
+
// Container node (parent) - use 'default' type to enable edge handles
|
|
155
|
+
// React Flow's 'group' type doesn't render connection handles
|
|
156
|
+
flowNodes.push({
|
|
157
|
+
id,
|
|
158
|
+
type: 'default',
|
|
159
|
+
position: { x: level * LEVEL_SPACING, y: yPos },
|
|
160
|
+
data: { label: '' },
|
|
161
|
+
style: {
|
|
162
|
+
width: containerWidth,
|
|
163
|
+
height: containerHeight,
|
|
164
|
+
backgroundColor: syntheticColor.bg,
|
|
165
|
+
border: `2px dashed ${syntheticColor.border}`,
|
|
166
|
+
borderRadius: '16px',
|
|
167
|
+
padding: 0,
|
|
168
|
+
},
|
|
169
|
+
sourcePosition: Position.Right,
|
|
170
|
+
targetPosition: Position.Left,
|
|
171
|
+
className: 'container-node',
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Header label inside container (no handles/connectors)
|
|
175
|
+
flowNodes.push({
|
|
176
|
+
id: `${id}-header`,
|
|
177
|
+
type: 'default',
|
|
178
|
+
position: { x: CONTAINER_PADDING, y: 8 },
|
|
179
|
+
parentId: id,
|
|
180
|
+
extent: 'parent',
|
|
181
|
+
draggable: false,
|
|
182
|
+
selectable: false,
|
|
183
|
+
connectable: false,
|
|
184
|
+
data: {
|
|
185
|
+
label: (
|
|
186
|
+
<div className="flex items-center gap-3">
|
|
187
|
+
<span className="text-xs font-mono text-indigo-300 uppercase">{nodeType}</span>
|
|
188
|
+
<span
|
|
189
|
+
className="text-xs px-1.5 py-0.5 rounded"
|
|
190
|
+
style={{ backgroundColor: stateColor.bg, color: stateColor.border }}
|
|
191
|
+
>
|
|
192
|
+
{node.state}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
className: 'header-label-node',
|
|
198
|
+
style: {
|
|
199
|
+
background: 'transparent',
|
|
200
|
+
border: 'none',
|
|
201
|
+
padding: 0,
|
|
202
|
+
width: 'auto',
|
|
203
|
+
pointerEvents: 'none' as const,
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Header task nodes inside container (vertical column layout)
|
|
208
|
+
headerChildren.forEach((childId, idx) => {
|
|
209
|
+
const childNode = nodes[childId]
|
|
210
|
+
if (!childNode) return
|
|
211
|
+
|
|
212
|
+
const childStateColor = stateColors[childNode.state] ?? stateColors.PENDING
|
|
213
|
+
const duration = formatDuration(childNode.duration_ms)
|
|
214
|
+
const startTime = formatTime(childNode.first_seen)
|
|
215
|
+
|
|
216
|
+
flowNodes.push({
|
|
217
|
+
id: childId,
|
|
218
|
+
position: {
|
|
219
|
+
x: CONTAINER_PADDING,
|
|
220
|
+
y: CONTAINER_HEADER + CONTAINER_PADDING + idx * (TASK_NODE_HEIGHT + CHILD_SPACING),
|
|
221
|
+
},
|
|
222
|
+
parentId: id,
|
|
223
|
+
extent: 'parent',
|
|
224
|
+
data: {
|
|
225
|
+
label: (
|
|
226
|
+
<Link
|
|
227
|
+
to="/tasks/$taskId"
|
|
228
|
+
params={{ taskId: childId }}
|
|
229
|
+
className="block p-2 text-left h-full"
|
|
230
|
+
>
|
|
231
|
+
<div className="text-xs text-slate-400 font-mono mb-1">{childId.slice(0, 8)}</div>
|
|
232
|
+
<div className="text-sm text-slate-100 font-medium">
|
|
233
|
+
{childNode.name.split('.').pop()}
|
|
234
|
+
</div>
|
|
235
|
+
{(startTime || duration) && (
|
|
236
|
+
<div className="text-xs text-slate-500 mt-1">
|
|
237
|
+
{startTime && <span>{startTime}</span>}
|
|
238
|
+
{startTime && duration && <span className="mx-1">·</span>}
|
|
239
|
+
{duration && <span>{duration}</span>}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
<div
|
|
243
|
+
className="text-xs mt-1 px-1.5 py-0.5 rounded inline-block"
|
|
244
|
+
style={{ backgroundColor: childStateColor.bg, color: childStateColor.border }}
|
|
245
|
+
>
|
|
246
|
+
{childNode.state}
|
|
247
|
+
</div>
|
|
248
|
+
</Link>
|
|
249
|
+
),
|
|
250
|
+
},
|
|
251
|
+
style: {
|
|
252
|
+
minWidth: TASK_NODE_MIN_WIDTH,
|
|
253
|
+
width: 'fit-content',
|
|
254
|
+
height: TASK_NODE_HEIGHT,
|
|
255
|
+
backgroundColor: '#0F172A',
|
|
256
|
+
border: `2px solid ${childStateColor.border}`,
|
|
257
|
+
borderRadius: '8px',
|
|
258
|
+
padding: 0,
|
|
259
|
+
},
|
|
260
|
+
sourcePosition: Position.Right,
|
|
261
|
+
targetPosition: Position.Left,
|
|
262
|
+
className: 'container-child',
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
visited.add(childId)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// Create callback nodes to the RIGHT of the container (for CHORD)
|
|
269
|
+
if (nodeType === 'CHORD') {
|
|
270
|
+
for (const childId of node.children) {
|
|
271
|
+
if (chordCallbacks.has(childId)) {
|
|
272
|
+
const callbackNode = nodes[childId]
|
|
273
|
+
if (!callbackNode) continue
|
|
274
|
+
|
|
275
|
+
const callbackStateColor = stateColors[callbackNode.state] ?? stateColors.PENDING
|
|
276
|
+
const callbackDuration = formatDuration(callbackNode.duration_ms)
|
|
277
|
+
const callbackStartTime = formatTime(callbackNode.first_seen)
|
|
278
|
+
|
|
279
|
+
// Position callback to the RIGHT of container, vertically centered
|
|
280
|
+
const callbackX = level * LEVEL_SPACING + containerWidth + 60
|
|
281
|
+
const callbackY = yPos + (containerHeight - TASK_NODE_HEIGHT) / 2
|
|
282
|
+
|
|
283
|
+
flowNodes.push({
|
|
284
|
+
id: childId,
|
|
285
|
+
position: { x: callbackX, y: callbackY },
|
|
286
|
+
data: {
|
|
287
|
+
label: (
|
|
288
|
+
<Link
|
|
289
|
+
to="/tasks/$taskId"
|
|
290
|
+
params={{ taskId: childId }}
|
|
291
|
+
className="block p-2 text-left h-full"
|
|
292
|
+
>
|
|
293
|
+
<div className="text-xs text-slate-400 font-mono mb-1">
|
|
294
|
+
{childId.slice(0, 8)}
|
|
295
|
+
</div>
|
|
296
|
+
<div className="text-sm text-slate-100 font-medium">
|
|
297
|
+
{callbackNode.name.split('.').pop()}
|
|
298
|
+
</div>
|
|
299
|
+
{(callbackStartTime || callbackDuration) && (
|
|
300
|
+
<div className="text-xs text-slate-500 mt-1">
|
|
301
|
+
{callbackStartTime && <span>{callbackStartTime}</span>}
|
|
302
|
+
{callbackStartTime && callbackDuration && <span className="mx-1">·</span>}
|
|
303
|
+
{callbackDuration && <span>{callbackDuration}</span>}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
<div
|
|
307
|
+
className="text-xs mt-1 px-1.5 py-0.5 rounded inline-block"
|
|
308
|
+
style={{
|
|
309
|
+
backgroundColor: callbackStateColor.bg,
|
|
310
|
+
color: callbackStateColor.border,
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{callbackNode.state}
|
|
314
|
+
</div>
|
|
315
|
+
</Link>
|
|
316
|
+
),
|
|
317
|
+
},
|
|
318
|
+
style: {
|
|
319
|
+
minWidth: TASK_NODE_MIN_WIDTH,
|
|
320
|
+
width: 'fit-content',
|
|
321
|
+
height: TASK_NODE_HEIGHT,
|
|
322
|
+
backgroundColor: '#0F172A',
|
|
323
|
+
border: `2px solid ${callbackStateColor.border}`,
|
|
324
|
+
borderRadius: '8px',
|
|
325
|
+
padding: 0,
|
|
326
|
+
},
|
|
327
|
+
sourcePosition: Position.Right,
|
|
328
|
+
targetPosition: Position.Left,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Edges from ALL header tasks to callback (chord aggregation)
|
|
332
|
+
// Each header task's completion contributes to triggering the callback
|
|
333
|
+
for (const headerId of headerChildren) {
|
|
334
|
+
flowEdges.push({
|
|
335
|
+
id: `${id}-callback-${headerId}-${childId}`,
|
|
336
|
+
source: headerId,
|
|
337
|
+
target: childId,
|
|
338
|
+
animated: callbackNode.state === 'STARTED',
|
|
339
|
+
style: { stroke: '#475569' },
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
visited.add(childId)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
// Regular task node
|
|
349
|
+
const yPos = positions.get(level) ?? 0
|
|
350
|
+
positions.set(level, yPos + 120)
|
|
351
|
+
|
|
352
|
+
const duration = formatDuration(node.duration_ms)
|
|
353
|
+
const startTime = formatTime(node.first_seen)
|
|
354
|
+
|
|
355
|
+
flowNodes.push({
|
|
356
|
+
id,
|
|
357
|
+
position: { x: level * LEVEL_SPACING, y: yPos },
|
|
358
|
+
data: {
|
|
359
|
+
label: (
|
|
360
|
+
<Link to="/tasks/$taskId" params={{ taskId: id }} className="block p-3 text-left">
|
|
361
|
+
<div className="text-xs text-slate-400 font-mono mb-1">{id.slice(0, 8)}</div>
|
|
362
|
+
<div className="text-sm text-slate-100 font-medium whitespace-nowrap">
|
|
363
|
+
{node.name.split('.').pop()}
|
|
364
|
+
</div>
|
|
365
|
+
{(startTime || duration) && (
|
|
366
|
+
<div className="text-xs text-slate-500 mt-1">
|
|
367
|
+
{startTime && <span>{startTime}</span>}
|
|
368
|
+
{startTime && duration && <span className="mx-1">·</span>}
|
|
369
|
+
{duration && <span>{duration}</span>}
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
<div
|
|
373
|
+
className="text-xs mt-2 px-1.5 py-0.5 rounded inline-block"
|
|
374
|
+
style={{ backgroundColor: stateColor.bg, color: stateColor.border }}
|
|
375
|
+
>
|
|
376
|
+
{node.state}
|
|
377
|
+
</div>
|
|
378
|
+
</Link>
|
|
379
|
+
),
|
|
380
|
+
},
|
|
381
|
+
style: {
|
|
382
|
+
padding: 0,
|
|
383
|
+
borderRadius: '8px',
|
|
384
|
+
border: `2px solid ${stateColor.border}`,
|
|
385
|
+
backgroundColor: '#0F172A',
|
|
386
|
+
minWidth: TASK_NODE_MIN_WIDTH,
|
|
387
|
+
width: 'fit-content',
|
|
388
|
+
},
|
|
389
|
+
sourcePosition: Position.Right,
|
|
390
|
+
targetPosition: Position.Left,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Edges to children (not inside a container)
|
|
394
|
+
for (const childId of node.children) {
|
|
395
|
+
// Skip edges to nodes that are inside containers
|
|
396
|
+
if (containerChildren.has(childId)) continue
|
|
397
|
+
|
|
398
|
+
const childNode = nodes[childId]
|
|
399
|
+
flowEdges.push({
|
|
400
|
+
id: `${id}-${childId}`,
|
|
401
|
+
source: id,
|
|
402
|
+
target: childId,
|
|
403
|
+
animated: childNode?.state === 'STARTED',
|
|
404
|
+
style: { stroke: '#475569' },
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Queue non-container children
|
|
410
|
+
for (const childId of node.children) {
|
|
411
|
+
if (!visited.has(childId) && !containerChildren.has(childId)) {
|
|
412
|
+
queue2.push(childId)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { nodes: flowNodes, edges: flowEdges }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function TaskGraph({ nodes: graphNodes, rootId }: TaskGraphProps) {
|
|
421
|
+
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
|
422
|
+
() => buildGraphElements(graphNodes, rootId),
|
|
423
|
+
[graphNodes, rootId],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
const [nodes, , onNodesChange] = useNodesState(initialNodes)
|
|
427
|
+
const [edges, , onEdgesChange] = useEdgesState(initialEdges)
|
|
428
|
+
|
|
429
|
+
if (Object.keys(graphNodes).length === 0) {
|
|
430
|
+
return (
|
|
431
|
+
<div className="flex items-center justify-center h-full text-slate-400">
|
|
432
|
+
No nodes in graph
|
|
433
|
+
</div>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<ReactFlow
|
|
439
|
+
nodes={nodes}
|
|
440
|
+
edges={edges}
|
|
441
|
+
onNodesChange={onNodesChange}
|
|
442
|
+
onEdgesChange={onEdgesChange}
|
|
443
|
+
fitView
|
|
444
|
+
fitViewOptions={{ padding: 0.2 }}
|
|
445
|
+
minZoom={0.1}
|
|
446
|
+
maxZoom={2}
|
|
447
|
+
proOptions={{ hideAttribution: true }}
|
|
448
|
+
>
|
|
449
|
+
<Controls className="bg-slate-800 border-slate-700" />
|
|
450
|
+
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#1E293B" />
|
|
451
|
+
</ReactFlow>
|
|
452
|
+
)
|
|
453
|
+
}
|