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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket context for sharing connection status globally.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
useCallback,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import type { TaskEvent } from '@/api/client'
|
|
16
|
+
|
|
17
|
+
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
|
18
|
+
|
|
19
|
+
interface WebSocketContextValue {
|
|
20
|
+
connectionStatus: ConnectionStatus
|
|
21
|
+
isConnected: boolean
|
|
22
|
+
lastEvent: TaskEvent | null
|
|
23
|
+
events: TaskEvent[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WebSocketContext = createContext<WebSocketContextValue | null>(null)
|
|
27
|
+
|
|
28
|
+
// WebSocket URL
|
|
29
|
+
const WS_URL = import.meta.env.DEV
|
|
30
|
+
? `ws://${window.location.hostname}:8000/stemtrace/ws`
|
|
31
|
+
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/stemtrace/ws`
|
|
32
|
+
|
|
33
|
+
// Reconnect interval when disconnected (3 seconds for quick recovery)
|
|
34
|
+
const RECONNECT_INTERVAL = 3000
|
|
35
|
+
|
|
36
|
+
interface WebSocketProviderProps {
|
|
37
|
+
children: ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function WebSocketProvider({ children }: WebSocketProviderProps) {
|
|
41
|
+
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting')
|
|
42
|
+
const [lastEvent, setLastEvent] = useState<TaskEvent | null>(null)
|
|
43
|
+
const [events, setEvents] = useState<TaskEvent[]>([])
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
46
|
+
const reconnectTimeoutRef = useRef<number | null>(null)
|
|
47
|
+
|
|
48
|
+
const connect = useCallback(() => {
|
|
49
|
+
// Don't reconnect if already connected or connecting
|
|
50
|
+
if (
|
|
51
|
+
wsRef.current?.readyState === WebSocket.OPEN ||
|
|
52
|
+
wsRef.current?.readyState === WebSocket.CONNECTING
|
|
53
|
+
) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setConnectionStatus('connecting')
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const ws = new WebSocket(WS_URL)
|
|
61
|
+
wsRef.current = ws
|
|
62
|
+
|
|
63
|
+
ws.onopen = () => {
|
|
64
|
+
setConnectionStatus('connected')
|
|
65
|
+
console.log('[WebSocket] Connected')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ws.onmessage = (event) => {
|
|
69
|
+
try {
|
|
70
|
+
const data = JSON.parse(event.data) as TaskEvent
|
|
71
|
+
setLastEvent(data)
|
|
72
|
+
setEvents((prev) => [data, ...prev].slice(0, 100)) // Keep last 100
|
|
73
|
+
|
|
74
|
+
// Invalidate queries to refresh data
|
|
75
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: ['graphs'] })
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('[WebSocket] Failed to parse message:', e)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ws.onclose = () => {
|
|
83
|
+
setConnectionStatus('disconnected')
|
|
84
|
+
wsRef.current = null
|
|
85
|
+
console.log('[WebSocket] Disconnected, will retry in 3s...')
|
|
86
|
+
|
|
87
|
+
// Schedule reconnect
|
|
88
|
+
if (reconnectTimeoutRef.current) {
|
|
89
|
+
clearTimeout(reconnectTimeoutRef.current)
|
|
90
|
+
}
|
|
91
|
+
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
92
|
+
connect()
|
|
93
|
+
}, RECONNECT_INTERVAL)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ws.onerror = () => {
|
|
97
|
+
setConnectionStatus('error')
|
|
98
|
+
// onclose will be called after onerror, which will trigger reconnect
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setConnectionStatus('error')
|
|
102
|
+
console.error('[WebSocket] Failed to connect:', e)
|
|
103
|
+
|
|
104
|
+
// Schedule reconnect on error
|
|
105
|
+
if (reconnectTimeoutRef.current) {
|
|
106
|
+
clearTimeout(reconnectTimeoutRef.current)
|
|
107
|
+
}
|
|
108
|
+
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
109
|
+
connect()
|
|
110
|
+
}, RECONNECT_INTERVAL)
|
|
111
|
+
}
|
|
112
|
+
}, [queryClient])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
connect()
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
if (reconnectTimeoutRef.current) {
|
|
119
|
+
clearTimeout(reconnectTimeoutRef.current)
|
|
120
|
+
}
|
|
121
|
+
if (wsRef.current) {
|
|
122
|
+
wsRef.current.close()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}, [connect])
|
|
126
|
+
|
|
127
|
+
const value: WebSocketContextValue = {
|
|
128
|
+
connectionStatus,
|
|
129
|
+
isConnected: connectionStatus === 'connected',
|
|
130
|
+
lastEvent,
|
|
131
|
+
events,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function useWebSocketContext(): WebSocketContextValue {
|
|
138
|
+
const context = useContext(WebSocketContext)
|
|
139
|
+
if (!context) {
|
|
140
|
+
throw new Error('useWebSocketContext must be used within a WebSocketProvider')
|
|
141
|
+
}
|
|
142
|
+
return context
|
|
143
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for infinite scroll behavior with intersection observer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
interface UseInfiniteScrollOptions {
|
|
8
|
+
hasNextPage: boolean | undefined
|
|
9
|
+
isFetchingNextPage: boolean
|
|
10
|
+
fetchNextPage: () => void
|
|
11
|
+
rootMargin?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useInfiniteScroll({
|
|
15
|
+
hasNextPage,
|
|
16
|
+
isFetchingNextPage,
|
|
17
|
+
fetchNextPage,
|
|
18
|
+
rootMargin = '200px',
|
|
19
|
+
}: UseInfiniteScrollOptions) {
|
|
20
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
21
|
+
|
|
22
|
+
const lastElementRef = useCallback(
|
|
23
|
+
(node: HTMLElement | null) => {
|
|
24
|
+
if (isFetchingNextPage) return
|
|
25
|
+
|
|
26
|
+
if (observerRef.current) {
|
|
27
|
+
observerRef.current.disconnect()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
observerRef.current = new IntersectionObserver(
|
|
31
|
+
(entries) => {
|
|
32
|
+
if (entries[0].isIntersecting && hasNextPage) {
|
|
33
|
+
fetchNextPage()
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{ rootMargin },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (node) {
|
|
40
|
+
observerRef.current.observe(node)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
[isFetchingNextPage, hasNextPage, fetchNextPage, rootMargin],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Cleanup on unmount
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
return () => {
|
|
49
|
+
if (observerRef.current) {
|
|
50
|
+
observerRef.current.disconnect()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
return { lastElementRef }
|
|
56
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "react-day-picker/style.css";
|
|
3
|
+
|
|
4
|
+
@theme {
|
|
5
|
+
--font-sans: "JetBrains Mono", ui-monospace, monospace;
|
|
6
|
+
|
|
7
|
+
/* Task state colors */
|
|
8
|
+
--color-pending: #6b7280;
|
|
9
|
+
--color-received: #6b7280;
|
|
10
|
+
--color-started: #3b82f6;
|
|
11
|
+
--color-success: #22c55e;
|
|
12
|
+
--color-failure: #ef4444;
|
|
13
|
+
--color-retry: #f59e0b;
|
|
14
|
+
--color-revoked: #8b5cf6;
|
|
15
|
+
--color-rejected: #dc2626;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
@apply antialiased;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.task-state-badge {
|
|
23
|
+
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.task-state-pending {
|
|
27
|
+
@apply bg-gray-500/20 text-gray-400;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.task-state-received {
|
|
31
|
+
@apply bg-gray-500/20 text-gray-400;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.task-state-started {
|
|
35
|
+
@apply bg-blue-500/20 text-blue-400;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.task-state-success {
|
|
39
|
+
@apply bg-green-500/20 text-green-400;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.task-state-failure {
|
|
43
|
+
@apply bg-red-500/20 text-red-400;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.task-state-retry {
|
|
47
|
+
@apply bg-amber-500/20 text-amber-400;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.task-state-revoked {
|
|
51
|
+
@apply bg-purple-500/20 text-purple-400;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.task-state-rejected {
|
|
55
|
+
@apply bg-red-600/20 text-red-500;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* React Flow customization */
|
|
59
|
+
.react-flow__node {
|
|
60
|
+
@apply rounded-lg border border-slate-700 bg-slate-800 shadow-lg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.react-flow__edge-path {
|
|
64
|
+
@apply stroke-slate-500;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.react-flow__controls {
|
|
68
|
+
@apply bg-slate-800 border-slate-700;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.react-flow__controls-button {
|
|
72
|
+
@apply bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Hide handles on header label nodes inside containers */
|
|
76
|
+
.header-label-node .react-flow__handle {
|
|
77
|
+
display: none !important;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Hide handles on container nodes (GROUP/CHORD) - edges connect but handles hidden */
|
|
81
|
+
.container-node .react-flow__handle {
|
|
82
|
+
opacity: 0 !important;
|
|
83
|
+
width: 8px;
|
|
84
|
+
height: 8px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Hide left handles on container child nodes (only show right for edges) */
|
|
88
|
+
.container-child .react-flow__handle-left,
|
|
89
|
+
.container-child .react-flow__handle-top,
|
|
90
|
+
.container-child .react-flow__handle-bottom {
|
|
91
|
+
display: none !important;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Make right handle on container children subtle */
|
|
95
|
+
.container-child .react-flow__handle-right {
|
|
96
|
+
width: 6px;
|
|
97
|
+
height: 6px;
|
|
98
|
+
opacity: 0.5;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Allow task nodes to expand based on content, but not container nodes */
|
|
102
|
+
.react-flow__node-default:not(.container-node) {
|
|
103
|
+
width: auto !important;
|
|
104
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { createRouter, RouterProvider } from '@tanstack/react-router'
|
|
3
|
+
import { StrictMode } from 'react'
|
|
4
|
+
import { createRoot } from 'react-dom/client'
|
|
5
|
+
import { WebSocketProvider } from './hooks/WebSocketContext'
|
|
6
|
+
import { routeTree } from './routeTree.gen'
|
|
7
|
+
import './index.css'
|
|
8
|
+
|
|
9
|
+
// Create query client
|
|
10
|
+
const queryClient = new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: {
|
|
13
|
+
staleTime: 5000,
|
|
14
|
+
refetchOnWindowFocus: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// Create router
|
|
20
|
+
const router = createRouter({
|
|
21
|
+
routeTree,
|
|
22
|
+
context: {
|
|
23
|
+
queryClient,
|
|
24
|
+
},
|
|
25
|
+
defaultPreload: 'intent',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Register router for type safety
|
|
29
|
+
declare module '@tanstack/react-router' {
|
|
30
|
+
interface Register {
|
|
31
|
+
router: typeof router
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Render
|
|
36
|
+
createRoot(document.getElementById('root')!).render(
|
|
37
|
+
<StrictMode>
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<WebSocketProvider>
|
|
40
|
+
<RouterProvider router={router} />
|
|
41
|
+
</WebSocketProvider>
|
|
42
|
+
</QueryClientProvider>
|
|
43
|
+
</StrictMode>,
|
|
44
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router'
|
|
3
|
+
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
|
4
|
+
import { useHealth } from '@/api/queries'
|
|
5
|
+
import { Tooltip } from '@/components/Tooltip'
|
|
6
|
+
import { useWebSocketContext } from '@/hooks/WebSocketContext'
|
|
7
|
+
|
|
8
|
+
interface RouterContext {
|
|
9
|
+
queryClient: QueryClient
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Route = createRootRouteWithContext<RouterContext>()({
|
|
13
|
+
component: RootLayout,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function RootLayout() {
|
|
17
|
+
// Get WebSocket connection status from context
|
|
18
|
+
const { connectionStatus, isConnected } = useWebSocketContext()
|
|
19
|
+
|
|
20
|
+
// Check if server is reachable via health endpoint
|
|
21
|
+
const { data: health, isError: isServerDown } = useHealth()
|
|
22
|
+
|
|
23
|
+
// Determine display status
|
|
24
|
+
// Priority: Server down > WS connected > WS connecting > Polling
|
|
25
|
+
let statusLabel: string
|
|
26
|
+
let statusColor: string
|
|
27
|
+
let statusTooltip: string
|
|
28
|
+
|
|
29
|
+
if (isServerDown) {
|
|
30
|
+
statusLabel = 'Offline'
|
|
31
|
+
statusColor = 'bg-red-500'
|
|
32
|
+
statusTooltip = 'Server is unreachable. Check if stemtrace is running.'
|
|
33
|
+
} else if (isConnected) {
|
|
34
|
+
statusLabel = 'Live'
|
|
35
|
+
statusColor = 'bg-green-500'
|
|
36
|
+
statusTooltip = 'Real-time updates via WebSocket'
|
|
37
|
+
} else if (connectionStatus === 'connecting') {
|
|
38
|
+
statusLabel = 'Connecting'
|
|
39
|
+
statusColor = 'bg-amber-500 animate-pulse'
|
|
40
|
+
statusTooltip = 'Connecting to WebSocket...'
|
|
41
|
+
} else {
|
|
42
|
+
statusLabel = 'Polling'
|
|
43
|
+
statusColor = 'bg-amber-500'
|
|
44
|
+
statusTooltip = 'Updates every 5s. For real-time updates, configure WebSocket proxy.'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="min-h-screen bg-slate-950">
|
|
49
|
+
{/* Header */}
|
|
50
|
+
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur sticky top-0 z-50">
|
|
51
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
52
|
+
<div className="flex items-center justify-between h-14">
|
|
53
|
+
{/* Logo */}
|
|
54
|
+
<Link to="/" className="flex items-baseline gap-2">
|
|
55
|
+
<span className="text-lg font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">
|
|
56
|
+
stemtrace
|
|
57
|
+
</span>
|
|
58
|
+
{health?.version && <span className="text-xs text-slate-500">v{health.version}</span>}
|
|
59
|
+
</Link>
|
|
60
|
+
|
|
61
|
+
{/* Navigation */}
|
|
62
|
+
<nav className="flex items-center gap-6">
|
|
63
|
+
<Link
|
|
64
|
+
to="/"
|
|
65
|
+
className="text-sm text-slate-400 hover:text-slate-100 transition-colors [&.active]:text-slate-100"
|
|
66
|
+
>
|
|
67
|
+
Tasks
|
|
68
|
+
</Link>
|
|
69
|
+
<Link
|
|
70
|
+
to="/graphs"
|
|
71
|
+
className="text-sm text-slate-400 hover:text-slate-100 transition-colors [&.active]:text-slate-100"
|
|
72
|
+
>
|
|
73
|
+
Graphs
|
|
74
|
+
</Link>
|
|
75
|
+
<Link
|
|
76
|
+
to="/registry"
|
|
77
|
+
className="text-sm text-slate-400 hover:text-slate-100 transition-colors [&.active]:text-slate-100"
|
|
78
|
+
>
|
|
79
|
+
Registry
|
|
80
|
+
</Link>
|
|
81
|
+
</nav>
|
|
82
|
+
|
|
83
|
+
{/* Status indicator with tooltip */}
|
|
84
|
+
<Tooltip content={statusTooltip} placement="bottom">
|
|
85
|
+
<div className="flex items-center gap-2 cursor-help">
|
|
86
|
+
<span className={`w-2 h-2 rounded-full ${statusColor}`} />
|
|
87
|
+
<span className="text-xs text-slate-500">{statusLabel}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</Tooltip>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
{/* Main content */}
|
|
95
|
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
96
|
+
<Outlet />
|
|
97
|
+
</main>
|
|
98
|
+
|
|
99
|
+
{/* Dev tools */}
|
|
100
|
+
{import.meta.env.DEV && <TanStackRouterDevtools position="bottom-right" />}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { useGraph } from '@/api/queries'
|
|
3
|
+
import { TaskGraph } from '@/components/TaskGraph'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/graph/$rootId')({
|
|
6
|
+
component: GraphDetailPage,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
function GraphDetailPage() {
|
|
10
|
+
const { rootId } = Route.useParams()
|
|
11
|
+
const { data, isLoading, error } = useGraph(rootId)
|
|
12
|
+
|
|
13
|
+
if (isLoading) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex items-center justify-center h-96">
|
|
16
|
+
<div className="animate-spin w-8 h-8 border-2 border-slate-600 border-t-green-500 rounded-full" />
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (error || !data) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="text-center py-12">
|
|
24
|
+
<p className="text-red-400">Failed to load graph</p>
|
|
25
|
+
<Link to="/graphs" className="text-blue-400 hover:underline mt-2 inline-block">
|
|
26
|
+
Back to graphs
|
|
27
|
+
</Link>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="space-y-6">
|
|
34
|
+
{/* Breadcrumb */}
|
|
35
|
+
<nav className="flex items-center gap-2 text-sm">
|
|
36
|
+
<Link to="/graphs" className="text-slate-400 hover:text-slate-200">
|
|
37
|
+
Graphs
|
|
38
|
+
</Link>
|
|
39
|
+
<span className="text-slate-600">/</span>
|
|
40
|
+
<span className="text-slate-200 font-mono">{rootId.slice(0, 8)}</span>
|
|
41
|
+
</nav>
|
|
42
|
+
|
|
43
|
+
{/* Graph visualization */}
|
|
44
|
+
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
|
45
|
+
<div className="h-[600px]">
|
|
46
|
+
<TaskGraph nodes={data.nodes} rootId={data.root_id} />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import type { DateRange } from 'react-day-picker'
|
|
3
|
+
import { useGraphsInfinite } from '@/api/queries'
|
|
4
|
+
import { DateRangePicker } from '@/components/DateRangePicker'
|
|
5
|
+
import { TaskStateBadge } from '@/components/TaskStateBadge'
|
|
6
|
+
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
|
7
|
+
import { formatDateParam, formatDuration, formatTime, parseDateParam } from '@/utils/format'
|
|
8
|
+
|
|
9
|
+
interface SearchParams {
|
|
10
|
+
from_date?: string
|
|
11
|
+
to_date?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Route = createFileRoute('/graphs')({
|
|
15
|
+
component: GraphsPage,
|
|
16
|
+
validateSearch: (search: Record<string, unknown>): SearchParams => ({
|
|
17
|
+
from_date: typeof search.from_date === 'string' ? search.from_date : undefined,
|
|
18
|
+
to_date: typeof search.to_date === 'string' ? search.to_date : undefined,
|
|
19
|
+
}),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function GraphsPage() {
|
|
23
|
+
const navigate = useNavigate()
|
|
24
|
+
const { from_date, to_date } = Route.useSearch()
|
|
25
|
+
|
|
26
|
+
const { data, isLoading, error, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
|
27
|
+
useGraphsInfinite({
|
|
28
|
+
from_date,
|
|
29
|
+
to_date,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const { lastElementRef } = useInfiniteScroll({
|
|
33
|
+
hasNextPage,
|
|
34
|
+
isFetchingNextPage,
|
|
35
|
+
fetchNextPage,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const dateRange: DateRange | undefined =
|
|
39
|
+
from_date || to_date
|
|
40
|
+
? {
|
|
41
|
+
from: parseDateParam(from_date),
|
|
42
|
+
to: parseDateParam(to_date),
|
|
43
|
+
}
|
|
44
|
+
: undefined
|
|
45
|
+
|
|
46
|
+
const handleDateChange = (range: DateRange | undefined) => {
|
|
47
|
+
navigate({
|
|
48
|
+
to: '/graphs',
|
|
49
|
+
search: {
|
|
50
|
+
from_date: formatDateParam(range?.from),
|
|
51
|
+
to_date: formatDateParam(range?.to),
|
|
52
|
+
},
|
|
53
|
+
replace: true,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allGraphs = data?.pages.flatMap((page) => page.graphs) ?? []
|
|
58
|
+
const total = data?.pages[0]?.total ?? 0
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="space-y-6">
|
|
62
|
+
<div className="flex items-start justify-between">
|
|
63
|
+
<div>
|
|
64
|
+
<h1 className="text-2xl font-bold text-slate-100">Task Graphs</h1>
|
|
65
|
+
<p className="text-sm text-slate-500 mt-1">
|
|
66
|
+
Visualize task execution flows and dependencies
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
<DateRangePicker value={dateRange} onChange={handleDateChange} />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{isLoading ? (
|
|
73
|
+
<GraphListSkeleton />
|
|
74
|
+
) : error ? (
|
|
75
|
+
<div className="text-center py-12 text-red-400">Failed to load graphs</div>
|
|
76
|
+
) : !allGraphs.length ? (
|
|
77
|
+
<EmptyState />
|
|
78
|
+
) : (
|
|
79
|
+
<div className="grid gap-4">
|
|
80
|
+
{allGraphs.map((graph, index) => {
|
|
81
|
+
const isLast = index === allGraphs.length - 1
|
|
82
|
+
const startTime = formatTime(graph.first_seen)
|
|
83
|
+
const duration = formatDuration(graph.duration_ms)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Link
|
|
87
|
+
key={graph.task_id}
|
|
88
|
+
ref={isLast ? lastElementRef : undefined}
|
|
89
|
+
to="/graph/$rootId"
|
|
90
|
+
params={{ rootId: graph.task_id }}
|
|
91
|
+
className="bg-slate-900 rounded-xl border border-slate-800 p-6 hover:border-slate-700 transition-colors"
|
|
92
|
+
>
|
|
93
|
+
<div className="flex items-start justify-between">
|
|
94
|
+
<div>
|
|
95
|
+
<h3 className="text-lg font-medium text-slate-100">{graph.name}</h3>
|
|
96
|
+
<p className="text-sm text-slate-500 font-mono mt-1">{graph.task_id}</p>
|
|
97
|
+
</div>
|
|
98
|
+
<TaskStateBadge state={graph.state} />
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex items-center gap-4 mt-4 text-sm text-slate-400">
|
|
102
|
+
<span>{graph.children.length} child tasks</span>
|
|
103
|
+
{startTime && (
|
|
104
|
+
<>
|
|
105
|
+
<span className="text-slate-600">·</span>
|
|
106
|
+
<span>{startTime}</span>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
{duration && (
|
|
110
|
+
<>
|
|
111
|
+
<span className="text-slate-600">·</span>
|
|
112
|
+
<span>{duration}</span>
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</Link>
|
|
117
|
+
)
|
|
118
|
+
})}
|
|
119
|
+
|
|
120
|
+
{/* Loading indicator */}
|
|
121
|
+
{isFetchingNextPage && <LoadingMore />}
|
|
122
|
+
|
|
123
|
+
{/* Pagination info */}
|
|
124
|
+
<div className="text-center text-sm text-slate-500 pt-4">
|
|
125
|
+
Showing {allGraphs.length} of {total} graphs
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function LoadingMore() {
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex justify-center py-4">
|
|
136
|
+
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
137
|
+
<svg
|
|
138
|
+
className="animate-spin h-4 w-4"
|
|
139
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
140
|
+
fill="none"
|
|
141
|
+
viewBox="0 0 24 24"
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
>
|
|
144
|
+
<circle
|
|
145
|
+
className="opacity-25"
|
|
146
|
+
cx="12"
|
|
147
|
+
cy="12"
|
|
148
|
+
r="10"
|
|
149
|
+
stroke="currentColor"
|
|
150
|
+
strokeWidth="4"
|
|
151
|
+
/>
|
|
152
|
+
<path
|
|
153
|
+
className="opacity-75"
|
|
154
|
+
fill="currentColor"
|
|
155
|
+
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"
|
|
156
|
+
/>
|
|
157
|
+
</svg>
|
|
158
|
+
Loading more...
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function EmptyState() {
|
|
165
|
+
return (
|
|
166
|
+
<div className="text-center py-16 bg-slate-900/50 rounded-xl border border-slate-800">
|
|
167
|
+
<div className="text-4xl mb-4">🌱</div>
|
|
168
|
+
<h3 className="text-lg font-medium text-slate-200">No task graphs yet</h3>
|
|
169
|
+
<p className="text-sm text-slate-500 mt-1">Run some Celery tasks to see them appear here</p>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function GraphListSkeleton() {
|
|
175
|
+
return (
|
|
176
|
+
<div className="grid gap-4 animate-pulse">
|
|
177
|
+
{[...Array(3)].map((_, i) => (
|
|
178
|
+
<div key={i} className="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
|
179
|
+
<div className="h-5 w-48 bg-slate-800 rounded" />
|
|
180
|
+
<div className="h-4 w-64 bg-slate-800 rounded mt-2" />
|
|
181
|
+
<div className="h-4 w-24 bg-slate-800 rounded mt-4" />
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|