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,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stemtrace-ui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"lint": "biome lint .",
|
|
11
|
+
"format": "biome format .",
|
|
12
|
+
"check": "biome check .",
|
|
13
|
+
"fix": "biome check --write .",
|
|
14
|
+
"generate": "tsr generate",
|
|
15
|
+
"typecheck": "tsr generate && tsc --noEmit",
|
|
16
|
+
"test": "playwright test",
|
|
17
|
+
"test:ui": "playwright test --ui",
|
|
18
|
+
"test:report": "playwright show-report"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@tanstack/react-query": "^5.60.0",
|
|
22
|
+
"@tanstack/react-router": "^1.82.0",
|
|
23
|
+
"@xyflow/react": "^12.3.0",
|
|
24
|
+
"clsx": "^2.1.1",
|
|
25
|
+
"date-fns": "^4.1.0",
|
|
26
|
+
"react": "^18.3.1",
|
|
27
|
+
"react-day-picker": "^9.13.0",
|
|
28
|
+
"react-dom": "^18.3.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@biomejs/biome": "^2.3.10",
|
|
32
|
+
"@playwright/test": "^1.57.0",
|
|
33
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
34
|
+
"@tanstack/router-cli": "^1.144.0",
|
|
35
|
+
"@tanstack/router-devtools": "^1.82.0",
|
|
36
|
+
"@tanstack/router-plugin": "^1.79.0",
|
|
37
|
+
"@types/node": "^25.0.3",
|
|
38
|
+
"@types/react": "^18.3.12",
|
|
39
|
+
"@types/react-dom": "^18.3.1",
|
|
40
|
+
"@vitejs/plugin-react": "^5.1.2",
|
|
41
|
+
"postcss": "^8.4.49",
|
|
42
|
+
"tailwindcss": "^4.1.18",
|
|
43
|
+
"typescript": "~5.9.3",
|
|
44
|
+
"vite": "^7.3.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Playwright configuration for stemtrace UI E2E tests.
|
|
5
|
+
*
|
|
6
|
+
* Run tests:
|
|
7
|
+
* npx playwright test
|
|
8
|
+
* npx playwright test --ui # Interactive mode
|
|
9
|
+
*
|
|
10
|
+
* Prerequisites:
|
|
11
|
+
* docker compose -f docker-compose.e2e.yml up -d --wait
|
|
12
|
+
*/
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
testDir: './tests',
|
|
15
|
+
fullyParallel: true,
|
|
16
|
+
forbidOnly: !!process.env.CI,
|
|
17
|
+
retries: process.env.CI ? 2 : 0,
|
|
18
|
+
workers: process.env.CI ? 1 : undefined,
|
|
19
|
+
reporter: [['html', { open: 'never' }], ['list']],
|
|
20
|
+
|
|
21
|
+
use: {
|
|
22
|
+
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8000',
|
|
23
|
+
trace: 'on-first-retry',
|
|
24
|
+
screenshot: 'only-on-failure',
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
name: 'chromium',
|
|
30
|
+
use: { ...devices['Desktop Chrome'] },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
// Don't start a web server - we expect docker-compose to be running
|
|
35
|
+
// webServer: {
|
|
36
|
+
// command: 'docker compose -f docker-compose.e2e.yml up',
|
|
37
|
+
// url: 'http://localhost:8000/api/health',
|
|
38
|
+
// reuseExistingServer: true,
|
|
39
|
+
// },
|
|
40
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API client for stemtrace backend.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Base URL determined by environment
|
|
6
|
+
export const API_BASE = import.meta.env.DEV ? '/api' : '/stemtrace/api'
|
|
7
|
+
|
|
8
|
+
// Types matching backend schemas
|
|
9
|
+
export type NodeType = 'TASK' | 'GROUP' | 'CHORD'
|
|
10
|
+
|
|
11
|
+
export interface TaskEvent {
|
|
12
|
+
task_id: string
|
|
13
|
+
name: string
|
|
14
|
+
state: string
|
|
15
|
+
timestamp: string
|
|
16
|
+
parent_id: string | null
|
|
17
|
+
root_id: string | null
|
|
18
|
+
group_id: string | null
|
|
19
|
+
trace_id: string | null
|
|
20
|
+
retries: number
|
|
21
|
+
// Enhanced event data
|
|
22
|
+
args: unknown[] | null
|
|
23
|
+
kwargs: Record<string, unknown> | null
|
|
24
|
+
result: unknown | null
|
|
25
|
+
exception: string | null
|
|
26
|
+
traceback: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RegisteredTask {
|
|
30
|
+
name: string
|
|
31
|
+
signature: string | null
|
|
32
|
+
docstring: string | null
|
|
33
|
+
module: string | null
|
|
34
|
+
bound: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TaskRegistryResponse {
|
|
38
|
+
tasks: RegisteredTask[]
|
|
39
|
+
total: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TaskNode {
|
|
43
|
+
task_id: string
|
|
44
|
+
name: string
|
|
45
|
+
state: string
|
|
46
|
+
node_type: NodeType
|
|
47
|
+
group_id: string | null
|
|
48
|
+
chord_id: string | null
|
|
49
|
+
parent_id: string | null
|
|
50
|
+
children: string[]
|
|
51
|
+
events: TaskEvent[]
|
|
52
|
+
first_seen: string | null
|
|
53
|
+
last_updated: string | null
|
|
54
|
+
duration_ms: number | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TaskListResponse {
|
|
58
|
+
tasks: TaskNode[]
|
|
59
|
+
total: number
|
|
60
|
+
limit: number
|
|
61
|
+
offset: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TaskDetailResponse {
|
|
65
|
+
task: TaskNode
|
|
66
|
+
children: TaskNode[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GraphNode {
|
|
70
|
+
task_id: string
|
|
71
|
+
name: string
|
|
72
|
+
state: string
|
|
73
|
+
node_type: NodeType
|
|
74
|
+
group_id: string | null
|
|
75
|
+
chord_id: string | null
|
|
76
|
+
parent_id: string | null
|
|
77
|
+
children: string[]
|
|
78
|
+
duration_ms: number | null
|
|
79
|
+
first_seen: string | null
|
|
80
|
+
last_updated: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GraphResponse {
|
|
84
|
+
root_id: string
|
|
85
|
+
nodes: Record<string, GraphNode>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GraphListResponse {
|
|
89
|
+
graphs: GraphNode[]
|
|
90
|
+
total: number
|
|
91
|
+
limit: number
|
|
92
|
+
offset: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HealthResponse {
|
|
96
|
+
status: string
|
|
97
|
+
version: string
|
|
98
|
+
consumer_running: boolean
|
|
99
|
+
websocket_connections: number
|
|
100
|
+
node_count: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FetchTasksParams {
|
|
104
|
+
limit?: number
|
|
105
|
+
offset?: number
|
|
106
|
+
state?: string
|
|
107
|
+
name?: string
|
|
108
|
+
from_date?: string
|
|
109
|
+
to_date?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface FetchGraphsParams {
|
|
113
|
+
limit?: number
|
|
114
|
+
offset?: number
|
|
115
|
+
from_date?: string
|
|
116
|
+
to_date?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// API functions
|
|
120
|
+
export async function fetchTasks(params?: FetchTasksParams): Promise<TaskListResponse> {
|
|
121
|
+
const searchParams = new URLSearchParams()
|
|
122
|
+
if (params?.limit) searchParams.set('limit', params.limit.toString())
|
|
123
|
+
if (params?.offset) searchParams.set('offset', params.offset.toString())
|
|
124
|
+
if (params?.state) searchParams.set('state', params.state)
|
|
125
|
+
if (params?.name) searchParams.set('name', params.name)
|
|
126
|
+
if (params?.from_date) searchParams.set('from_date', params.from_date)
|
|
127
|
+
if (params?.to_date) searchParams.set('to_date', params.to_date)
|
|
128
|
+
|
|
129
|
+
const url = `${API_BASE}/tasks${searchParams.toString() ? `?${searchParams}` : ''}`
|
|
130
|
+
const response = await fetch(url)
|
|
131
|
+
if (!response.ok) throw new Error('Failed to fetch tasks')
|
|
132
|
+
return response.json()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function fetchTask(taskId: string): Promise<TaskDetailResponse> {
|
|
136
|
+
const response = await fetch(`${API_BASE}/tasks/${taskId}`)
|
|
137
|
+
if (!response.ok) throw new Error('Failed to fetch task')
|
|
138
|
+
return response.json()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function fetchGraphs(params?: FetchGraphsParams): Promise<GraphListResponse> {
|
|
142
|
+
const searchParams = new URLSearchParams()
|
|
143
|
+
if (params?.limit) searchParams.set('limit', params.limit.toString())
|
|
144
|
+
if (params?.offset) searchParams.set('offset', params.offset.toString())
|
|
145
|
+
if (params?.from_date) searchParams.set('from_date', params.from_date)
|
|
146
|
+
if (params?.to_date) searchParams.set('to_date', params.to_date)
|
|
147
|
+
|
|
148
|
+
const url = `${API_BASE}/graphs${searchParams.toString() ? `?${searchParams}` : ''}`
|
|
149
|
+
const response = await fetch(url)
|
|
150
|
+
if (!response.ok) throw new Error('Failed to fetch graphs')
|
|
151
|
+
return response.json()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function fetchGraph(rootId: string): Promise<GraphResponse> {
|
|
155
|
+
const response = await fetch(`${API_BASE}/graphs/${rootId}`)
|
|
156
|
+
if (!response.ok) throw new Error('Failed to fetch graph')
|
|
157
|
+
return response.json()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function fetchHealth(): Promise<HealthResponse> {
|
|
161
|
+
const response = await fetch(`${API_BASE}/health`)
|
|
162
|
+
if (!response.ok) throw new Error('Failed to fetch health')
|
|
163
|
+
return response.json()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function fetchTaskRegistry(query?: string): Promise<TaskRegistryResponse> {
|
|
167
|
+
const url = query
|
|
168
|
+
? `${API_BASE}/tasks/registry?query=${encodeURIComponent(query)}`
|
|
169
|
+
: `${API_BASE}/tasks/registry`
|
|
170
|
+
const response = await fetch(url)
|
|
171
|
+
if (!response.ok) throw new Error('Failed to fetch task registry')
|
|
172
|
+
return response.json()
|
|
173
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack Query hooks for API data fetching.
|
|
3
|
+
*
|
|
4
|
+
* Polling is only enabled when WebSocket is disconnected.
|
|
5
|
+
* When WS is connected, real-time updates come via WS invalidation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
|
9
|
+
import { useWebSocketContext } from '@/hooks/WebSocketContext'
|
|
10
|
+
import {
|
|
11
|
+
type FetchGraphsParams,
|
|
12
|
+
type FetchTasksParams,
|
|
13
|
+
fetchGraph,
|
|
14
|
+
fetchGraphs,
|
|
15
|
+
fetchHealth,
|
|
16
|
+
fetchTask,
|
|
17
|
+
fetchTaskRegistry,
|
|
18
|
+
fetchTasks,
|
|
19
|
+
type GraphListResponse,
|
|
20
|
+
type GraphResponse,
|
|
21
|
+
type HealthResponse,
|
|
22
|
+
type TaskDetailResponse,
|
|
23
|
+
type TaskListResponse,
|
|
24
|
+
type TaskRegistryResponse,
|
|
25
|
+
} from './client'
|
|
26
|
+
|
|
27
|
+
// Polling interval when WebSocket is disconnected
|
|
28
|
+
const POLL_INTERVAL = 5000
|
|
29
|
+
const PAGE_SIZE = 50
|
|
30
|
+
|
|
31
|
+
export function useTasks(params?: Omit<FetchTasksParams, 'limit' | 'offset'>) {
|
|
32
|
+
const { isConnected } = useWebSocketContext()
|
|
33
|
+
|
|
34
|
+
return useQuery<TaskListResponse>({
|
|
35
|
+
queryKey: ['tasks', params],
|
|
36
|
+
queryFn: () => fetchTasks({ ...params, limit: PAGE_SIZE }),
|
|
37
|
+
// Only poll when WS is disconnected
|
|
38
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useTasksInfinite(params?: Omit<FetchTasksParams, 'limit' | 'offset'>) {
|
|
43
|
+
const { isConnected } = useWebSocketContext()
|
|
44
|
+
|
|
45
|
+
return useInfiniteQuery<
|
|
46
|
+
TaskListResponse,
|
|
47
|
+
Error,
|
|
48
|
+
InfiniteData<TaskListResponse>,
|
|
49
|
+
unknown[],
|
|
50
|
+
number
|
|
51
|
+
>({
|
|
52
|
+
queryKey: ['tasks-infinite', params],
|
|
53
|
+
queryFn: ({ pageParam }) =>
|
|
54
|
+
fetchTasks({
|
|
55
|
+
...params,
|
|
56
|
+
limit: PAGE_SIZE,
|
|
57
|
+
offset: pageParam,
|
|
58
|
+
}),
|
|
59
|
+
initialPageParam: 0,
|
|
60
|
+
getNextPageParam: (lastPage) => {
|
|
61
|
+
const nextOffset = lastPage.offset + lastPage.limit
|
|
62
|
+
return nextOffset < lastPage.total ? nextOffset : undefined
|
|
63
|
+
},
|
|
64
|
+
// Only poll first page when WS is disconnected
|
|
65
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useTask(taskId: string) {
|
|
70
|
+
const { isConnected } = useWebSocketContext()
|
|
71
|
+
|
|
72
|
+
return useQuery<TaskDetailResponse>({
|
|
73
|
+
queryKey: ['tasks', taskId],
|
|
74
|
+
queryFn: () => fetchTask(taskId),
|
|
75
|
+
enabled: !!taskId,
|
|
76
|
+
// Only poll when WS is disconnected
|
|
77
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useGraphs(params?: Omit<FetchGraphsParams, 'limit' | 'offset'>) {
|
|
82
|
+
const { isConnected } = useWebSocketContext()
|
|
83
|
+
|
|
84
|
+
return useQuery<GraphListResponse>({
|
|
85
|
+
queryKey: ['graphs', params],
|
|
86
|
+
queryFn: () => fetchGraphs({ ...params, limit: PAGE_SIZE }),
|
|
87
|
+
// Only poll when WS is disconnected
|
|
88
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useGraphsInfinite(params?: Omit<FetchGraphsParams, 'limit' | 'offset'>) {
|
|
93
|
+
const { isConnected } = useWebSocketContext()
|
|
94
|
+
|
|
95
|
+
return useInfiniteQuery<
|
|
96
|
+
GraphListResponse,
|
|
97
|
+
Error,
|
|
98
|
+
InfiniteData<GraphListResponse>,
|
|
99
|
+
unknown[],
|
|
100
|
+
number
|
|
101
|
+
>({
|
|
102
|
+
queryKey: ['graphs-infinite', params],
|
|
103
|
+
queryFn: ({ pageParam }) =>
|
|
104
|
+
fetchGraphs({
|
|
105
|
+
...params,
|
|
106
|
+
limit: PAGE_SIZE,
|
|
107
|
+
offset: pageParam,
|
|
108
|
+
}),
|
|
109
|
+
initialPageParam: 0,
|
|
110
|
+
getNextPageParam: (lastPage) => {
|
|
111
|
+
const nextOffset = lastPage.offset + lastPage.limit
|
|
112
|
+
return nextOffset < lastPage.total ? nextOffset : undefined
|
|
113
|
+
},
|
|
114
|
+
// Only poll first page when WS is disconnected
|
|
115
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useGraph(rootId: string) {
|
|
120
|
+
const { isConnected } = useWebSocketContext()
|
|
121
|
+
|
|
122
|
+
return useQuery<GraphResponse>({
|
|
123
|
+
queryKey: ['graphs', rootId],
|
|
124
|
+
queryFn: () => fetchGraph(rootId),
|
|
125
|
+
enabled: !!rootId,
|
|
126
|
+
// Only poll when WS is disconnected
|
|
127
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function useHealth() {
|
|
132
|
+
const { isConnected } = useWebSocketContext()
|
|
133
|
+
|
|
134
|
+
return useQuery<HealthResponse>({
|
|
135
|
+
queryKey: ['health'],
|
|
136
|
+
queryFn: fetchHealth,
|
|
137
|
+
// Sync with data polling interval so status changes are consistent
|
|
138
|
+
refetchInterval: isConnected ? false : POLL_INTERVAL,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useTaskRegistry(query?: string) {
|
|
143
|
+
const { isConnected } = useWebSocketContext()
|
|
144
|
+
|
|
145
|
+
return useQuery<TaskRegistryResponse>({
|
|
146
|
+
queryKey: ['taskRegistry', query],
|
|
147
|
+
queryFn: () => fetchTaskRegistry(query),
|
|
148
|
+
// Registry refreshes less frequently
|
|
149
|
+
refetchInterval: isConnected ? false : 30000,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date range picker with preset ranges.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
endOfDay,
|
|
7
|
+
endOfMonth,
|
|
8
|
+
endOfWeek,
|
|
9
|
+
format,
|
|
10
|
+
startOfDay,
|
|
11
|
+
startOfMonth,
|
|
12
|
+
startOfWeek,
|
|
13
|
+
subDays,
|
|
14
|
+
subMonths,
|
|
15
|
+
subWeeks,
|
|
16
|
+
} from 'date-fns'
|
|
17
|
+
import { useEffect, useRef, useState } from 'react'
|
|
18
|
+
import { type DateRange, DayPicker } from 'react-day-picker'
|
|
19
|
+
|
|
20
|
+
interface DateRangePickerProps {
|
|
21
|
+
value: DateRange | undefined
|
|
22
|
+
onChange: (range: DateRange | undefined) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Preset {
|
|
26
|
+
label: string
|
|
27
|
+
getValue: () => DateRange
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const presets: Preset[] = [
|
|
31
|
+
{
|
|
32
|
+
label: 'Today',
|
|
33
|
+
getValue: () => ({
|
|
34
|
+
from: startOfDay(new Date()),
|
|
35
|
+
to: endOfDay(new Date()),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: 'Yesterday',
|
|
40
|
+
getValue: () => {
|
|
41
|
+
const yesterday = subDays(new Date(), 1)
|
|
42
|
+
return {
|
|
43
|
+
from: startOfDay(yesterday),
|
|
44
|
+
to: endOfDay(yesterday),
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: 'This Week',
|
|
50
|
+
getValue: () => ({
|
|
51
|
+
from: startOfWeek(new Date(), { weekStartsOn: 1 }),
|
|
52
|
+
to: endOfWeek(new Date(), { weekStartsOn: 1 }),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: 'Last Week',
|
|
57
|
+
getValue: () => {
|
|
58
|
+
const lastWeek = subWeeks(new Date(), 1)
|
|
59
|
+
return {
|
|
60
|
+
from: startOfWeek(lastWeek, { weekStartsOn: 1 }),
|
|
61
|
+
to: endOfWeek(lastWeek, { weekStartsOn: 1 }),
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: 'Last 7 Days',
|
|
67
|
+
getValue: () => ({
|
|
68
|
+
from: startOfDay(subDays(new Date(), 6)),
|
|
69
|
+
to: endOfDay(new Date()),
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: 'Last 30 Days',
|
|
74
|
+
getValue: () => ({
|
|
75
|
+
from: startOfDay(subDays(new Date(), 29)),
|
|
76
|
+
to: endOfDay(new Date()),
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'This Month',
|
|
81
|
+
getValue: () => ({
|
|
82
|
+
from: startOfMonth(new Date()),
|
|
83
|
+
to: endOfMonth(new Date()),
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: 'Last Month',
|
|
88
|
+
getValue: () => {
|
|
89
|
+
const lastMonth = subMonths(new Date(), 1)
|
|
90
|
+
return {
|
|
91
|
+
from: startOfMonth(lastMonth),
|
|
92
|
+
to: endOfMonth(lastMonth),
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|
99
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
100
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
|
|
102
|
+
// Close on click outside
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
function handleClickOutside(event: MouseEvent) {
|
|
105
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
106
|
+
setIsOpen(false)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
110
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
const formatRange = () => {
|
|
114
|
+
if (!value?.from) return 'Select date range'
|
|
115
|
+
if (!value.to) return format(value.from, 'MMM d, yyyy')
|
|
116
|
+
if (format(value.from, 'yyyy-MM-dd') === format(value.to, 'yyyy-MM-dd')) {
|
|
117
|
+
return format(value.from, 'MMM d, yyyy')
|
|
118
|
+
}
|
|
119
|
+
return `${format(value.from, 'MMM d')} - ${format(value.to, 'MMM d, yyyy')}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handlePreset = (preset: Preset) => {
|
|
123
|
+
onChange(preset.getValue())
|
|
124
|
+
setIsOpen(false)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const handleClear = () => {
|
|
128
|
+
onChange(undefined)
|
|
129
|
+
setIsOpen(false)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div ref={containerRef} className="relative">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
137
|
+
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors whitespace-nowrap ${
|
|
138
|
+
value?.from
|
|
139
|
+
? 'bg-slate-700 border-slate-600 text-slate-100'
|
|
140
|
+
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-600'
|
|
141
|
+
}`}
|
|
142
|
+
>
|
|
143
|
+
<CalendarIcon />
|
|
144
|
+
<span>{formatRange()}</span>
|
|
145
|
+
{value?.from && (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={(e) => {
|
|
149
|
+
e.stopPropagation()
|
|
150
|
+
handleClear()
|
|
151
|
+
}}
|
|
152
|
+
className="ml-1 text-slate-500 hover:text-slate-300"
|
|
153
|
+
>
|
|
154
|
+
<XIcon />
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</button>
|
|
158
|
+
|
|
159
|
+
{isOpen && (
|
|
160
|
+
<div className="absolute top-full mt-2 right-0 z-50 flex bg-slate-900 rounded-xl border border-slate-700 shadow-2xl overflow-hidden">
|
|
161
|
+
{/* Presets sidebar */}
|
|
162
|
+
<div className="w-36 border-r border-slate-700 py-2">
|
|
163
|
+
{presets.map((preset) => (
|
|
164
|
+
<button
|
|
165
|
+
key={preset.label}
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={() => handlePreset(preset)}
|
|
168
|
+
className="w-full px-3 py-1.5 text-left text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors"
|
|
169
|
+
>
|
|
170
|
+
{preset.label}
|
|
171
|
+
</button>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Calendar */}
|
|
176
|
+
<div className="p-3 relative">
|
|
177
|
+
<DayPicker
|
|
178
|
+
mode="range"
|
|
179
|
+
selected={value}
|
|
180
|
+
onSelect={onChange}
|
|
181
|
+
numberOfMonths={2}
|
|
182
|
+
classNames={{
|
|
183
|
+
root: 'text-slate-200',
|
|
184
|
+
months: 'flex gap-4',
|
|
185
|
+
month: 'space-y-3',
|
|
186
|
+
month_caption: 'flex justify-center items-center h-8',
|
|
187
|
+
caption_label: 'text-sm font-medium text-slate-200',
|
|
188
|
+
nav: 'absolute top-3 left-0 right-0 flex justify-between px-1',
|
|
189
|
+
button_previous:
|
|
190
|
+
'p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200',
|
|
191
|
+
button_next: 'p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200',
|
|
192
|
+
weekdays: 'flex',
|
|
193
|
+
weekday: 'w-8 text-center text-xs font-medium text-slate-500',
|
|
194
|
+
week: 'flex',
|
|
195
|
+
day: 'w-8 h-8 text-center text-sm p-0',
|
|
196
|
+
day_button:
|
|
197
|
+
'w-8 h-8 rounded-md hover:bg-slate-800 transition-colors text-slate-300 hover:text-slate-100 disabled:text-slate-600 disabled:hover:bg-transparent',
|
|
198
|
+
selected: 'bg-emerald-600 text-white hover:bg-emerald-600',
|
|
199
|
+
range_start: 'rounded-l-md',
|
|
200
|
+
range_end: 'rounded-r-md',
|
|
201
|
+
range_middle: 'bg-emerald-600/30 rounded-none',
|
|
202
|
+
today: 'font-bold text-emerald-400',
|
|
203
|
+
disabled: 'text-slate-600',
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function CalendarIcon() {
|
|
214
|
+
return (
|
|
215
|
+
<svg
|
|
216
|
+
className="w-4 h-4"
|
|
217
|
+
fill="none"
|
|
218
|
+
viewBox="0 0 24 24"
|
|
219
|
+
stroke="currentColor"
|
|
220
|
+
aria-hidden="true"
|
|
221
|
+
>
|
|
222
|
+
<path
|
|
223
|
+
strokeLinecap="round"
|
|
224
|
+
strokeLinejoin="round"
|
|
225
|
+
strokeWidth={2}
|
|
226
|
+
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
227
|
+
/>
|
|
228
|
+
</svg>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function XIcon() {
|
|
233
|
+
return (
|
|
234
|
+
<svg
|
|
235
|
+
className="w-3 h-3"
|
|
236
|
+
fill="none"
|
|
237
|
+
viewBox="0 0 24 24"
|
|
238
|
+
stroke="currentColor"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
>
|
|
241
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
242
|
+
</svg>
|
|
243
|
+
)
|
|
244
|
+
}
|