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