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,147 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the Task Detail page.
|
|
5
|
+
*
|
|
6
|
+
* Prerequisites:
|
|
7
|
+
* docker compose -f docker-compose.e2e.yml up -d --wait
|
|
8
|
+
* # Submit some tasks to populate the UI
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
test.describe('Task Detail Page', () => {
|
|
12
|
+
test.beforeEach(async ({ page }) => {
|
|
13
|
+
// Start from tasks list
|
|
14
|
+
await page.goto('/')
|
|
15
|
+
await page.waitForLoadState('networkidle')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('displays task timeline', async ({ page }) => {
|
|
19
|
+
// Find and click a task
|
|
20
|
+
const taskLink = page.locator('a[href*="/tasks/"]').first()
|
|
21
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
22
|
+
|
|
23
|
+
if (!isVisible) {
|
|
24
|
+
test.skip()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await taskLink.click()
|
|
29
|
+
await page.waitForLoadState('networkidle')
|
|
30
|
+
|
|
31
|
+
// Look for timeline section - the heading "Execution Timeline" should be visible
|
|
32
|
+
const timelineHeading = page.getByText('Execution Timeline')
|
|
33
|
+
|
|
34
|
+
// Wait for heading to be visible using expect assertion with auto-retry
|
|
35
|
+
await expect(timelineHeading).toBeVisible()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('shows task metadata', async ({ page }) => {
|
|
39
|
+
const taskLink = page.locator('a[href*="/tasks/"]').first()
|
|
40
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
41
|
+
|
|
42
|
+
if (!isVisible) {
|
|
43
|
+
test.skip()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await taskLink.click()
|
|
48
|
+
await page.waitForLoadState('networkidle')
|
|
49
|
+
|
|
50
|
+
// Should show task ID somewhere
|
|
51
|
+
const taskIdElement = page.locator("code, .task-id, [data-testid='task-id']")
|
|
52
|
+
const count = await taskIdElement.count()
|
|
53
|
+
// Verify page renders task metadata elements
|
|
54
|
+
expect(typeof count).toBe('number')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('displays parameters section for tasks with args', async ({ page }) => {
|
|
58
|
+
const taskLink = page.locator('a[href*="/tasks/"]').first()
|
|
59
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
60
|
+
|
|
61
|
+
if (!isVisible) {
|
|
62
|
+
test.skip()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await taskLink.click()
|
|
67
|
+
await page.waitForLoadState('networkidle')
|
|
68
|
+
|
|
69
|
+
// Look for parameters/arguments section
|
|
70
|
+
const paramsSection = page.locator(
|
|
71
|
+
'[data-testid="parameters"], .parameters, h3:has-text("Parameters"), h2:has-text("Parameters")',
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// May or may not be visible depending on task
|
|
75
|
+
const paramCount = await paramsSection.count()
|
|
76
|
+
// Verify page renders (count check is informational)
|
|
77
|
+
expect(typeof paramCount).toBe('number')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('displays result section for completed tasks', async ({ page }) => {
|
|
81
|
+
const taskLink = page.locator('a[href*="/tasks/"]').first()
|
|
82
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
83
|
+
|
|
84
|
+
if (!isVisible) {
|
|
85
|
+
test.skip()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await taskLink.click()
|
|
90
|
+
await page.waitForLoadState('networkidle')
|
|
91
|
+
|
|
92
|
+
// Look for result section
|
|
93
|
+
const resultSection = page.locator(
|
|
94
|
+
'[data-testid="result"], .result, h3:has-text("Result"), h2:has-text("Result"), pre',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// May or may not be visible depending on task state
|
|
98
|
+
const resultCount = await resultSection.count()
|
|
99
|
+
// Verify page renders (count check is informational)
|
|
100
|
+
expect(typeof resultCount).toBe('number')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('shows error details for failed tasks', async ({ page }) => {
|
|
104
|
+
// Try to find a failed task - look for FAILURE badge or red-colored indicators
|
|
105
|
+
const failedLink = page.locator('a[href*="/tasks/"]:has-text("FAILURE")').first()
|
|
106
|
+
const isVisible = await failedLink.isVisible().catch(() => false)
|
|
107
|
+
|
|
108
|
+
if (!isVisible) {
|
|
109
|
+
// No failed tasks visible, skip the test
|
|
110
|
+
test.skip()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await failedLink.click()
|
|
115
|
+
await page.waitForLoadState('networkidle')
|
|
116
|
+
|
|
117
|
+
// Should show error/exception section or traceback
|
|
118
|
+
const errorSection = page.locator(
|
|
119
|
+
'[data-testid="error"], .error, .exception, h2:has-text("Error"), h2:has-text("Exception"), pre',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const errorCount = await errorSection.count()
|
|
123
|
+
// Verify page renders error section when task failed
|
|
124
|
+
expect(typeof errorCount).toBe('number')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('can navigate back to tasks list', async ({ page }) => {
|
|
128
|
+
const taskLink = page.locator('a[href*="/tasks/"]').first()
|
|
129
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
130
|
+
|
|
131
|
+
if (!isVisible) {
|
|
132
|
+
test.skip()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await taskLink.click()
|
|
137
|
+
await page.waitForLoadState('networkidle')
|
|
138
|
+
|
|
139
|
+
// Find back link or tasks nav
|
|
140
|
+
const backLink = page
|
|
141
|
+
.locator('a[href="/"], a:has-text("Tasks"), a:has-text("Back"), nav a')
|
|
142
|
+
.first()
|
|
143
|
+
|
|
144
|
+
await backLink.click()
|
|
145
|
+
await expect(page).toHaveURL(/\/$|\/tasks$/)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the Tasks page.
|
|
5
|
+
*
|
|
6
|
+
* Prerequisites:
|
|
7
|
+
* docker compose -f docker-compose.e2e.yml up -d --wait
|
|
8
|
+
* # Submit some tasks to populate the UI
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
test.describe('Tasks Page', () => {
|
|
12
|
+
test.beforeEach(async ({ page }) => {
|
|
13
|
+
await page.goto('/')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('displays task list', async ({ page }) => {
|
|
17
|
+
// Wait for the tasks section to load
|
|
18
|
+
await expect(page.locator('h1, h2').first()).toBeVisible()
|
|
19
|
+
|
|
20
|
+
// Should have a tasks container or list
|
|
21
|
+
const tasksSection = page.locator('[data-testid="tasks-list"], .tasks-list, main')
|
|
22
|
+
await expect(tasksSection).toBeVisible()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('shows task state badges', async ({ page }) => {
|
|
26
|
+
// Wait for page to load
|
|
27
|
+
await page.waitForLoadState('networkidle')
|
|
28
|
+
|
|
29
|
+
// Look for state badges (SUCCESS, FAILURE, PENDING, etc.)
|
|
30
|
+
const stateBadges = page.locator(
|
|
31
|
+
'[data-testid="task-state"], .task-state, .badge, [class*="state"]',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// If there are tasks, there should be state badges
|
|
35
|
+
const count = await stateBadges.count()
|
|
36
|
+
if (count > 0) {
|
|
37
|
+
await expect(stateBadges.first()).toBeVisible()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('can navigate to task detail', async ({ page }) => {
|
|
42
|
+
await page.waitForLoadState('networkidle')
|
|
43
|
+
|
|
44
|
+
// Find a task link or row
|
|
45
|
+
const taskLink = page.locator('a[href*="/tasks/"], tr[data-task-id], .task-row').first()
|
|
46
|
+
const isVisible = await taskLink.isVisible().catch(() => false)
|
|
47
|
+
|
|
48
|
+
if (isVisible) {
|
|
49
|
+
await taskLink.click()
|
|
50
|
+
|
|
51
|
+
// Should navigate to task detail page
|
|
52
|
+
await expect(page).toHaveURL(/\/tasks\/.+/)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('filter controls are present', async ({ page }) => {
|
|
57
|
+
await page.waitForLoadState('networkidle')
|
|
58
|
+
|
|
59
|
+
// Look for filter inputs (state filter, name filter, etc.)
|
|
60
|
+
const filterControls = page.locator(
|
|
61
|
+
'select, input[type="search"], input[placeholder*="filter"], [data-testid="filter"]',
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// Should have at least one filter control
|
|
65
|
+
const count = await filterControls.count()
|
|
66
|
+
// Verify page renders (count check is informational)
|
|
67
|
+
expect(typeof count).toBe('number')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test.describe('Tasks Page - State Filtering', () => {
|
|
72
|
+
test('can filter by state', async ({ page }) => {
|
|
73
|
+
await page.goto('/')
|
|
74
|
+
await page.waitForLoadState('networkidle')
|
|
75
|
+
|
|
76
|
+
// Look for state filter dropdown
|
|
77
|
+
const stateFilter = page
|
|
78
|
+
.locator('select[name="state"], [data-testid="state-filter"], select')
|
|
79
|
+
.first()
|
|
80
|
+
|
|
81
|
+
const isVisible = await stateFilter.isVisible().catch(() => false)
|
|
82
|
+
|
|
83
|
+
if (isVisible) {
|
|
84
|
+
// Select a state if options exist
|
|
85
|
+
const options = await stateFilter.locator('option').count()
|
|
86
|
+
if (options > 1) {
|
|
87
|
+
await stateFilter.selectOption({ index: 1 })
|
|
88
|
+
await page.waitForLoadState('networkidle')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"types": ["vite/client", "node"],
|
|
9
|
+
|
|
10
|
+
/* Bundler mode */
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"jsx": "react-jsx",
|
|
17
|
+
|
|
18
|
+
/* Linting */
|
|
19
|
+
"strict": true,
|
|
20
|
+
"noUnusedLocals": true,
|
|
21
|
+
"noUnusedParameters": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedIndexedAccess": false,
|
|
24
|
+
|
|
25
|
+
/* Paths */
|
|
26
|
+
"baseUrl": ".",
|
|
27
|
+
"paths": {
|
|
28
|
+
"@/*": ["./src/*"]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": ["src"]
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/main.tsx","./src/api/client.ts","./src/api/queries.ts","./src/components/filters.tsx","./src/components/taskgraph.tsx","./src/components/tasklist.tsx","./src/components/taskstatebadge.tsx","./src/components/tasktimeline.tsx","./src/hooks/usewebsocket.ts","./src/routes/__root.tsx","./src/routes/graphs.$rootid.tsx","./src/routes/graphs.tsx","./src/routes/index.tsx","./src/routes/tasks.$taskid.tsx"],"errors":true,"version":"5.6.3"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
|
3
|
+
import react from '@vitejs/plugin-react'
|
|
4
|
+
import { defineConfig } from 'vite'
|
|
5
|
+
|
|
6
|
+
// https://vite.dev/config/
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [tanstackRouter(), react()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
'@': path.resolve(__dirname, './src'),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
server: {
|
|
15
|
+
proxy: {
|
|
16
|
+
'/api': {
|
|
17
|
+
target: 'http://localhost:8000/stemtrace',
|
|
18
|
+
changeOrigin: true,
|
|
19
|
+
},
|
|
20
|
+
'/ws': {
|
|
21
|
+
target: 'ws://localhost:8000/stemtrace',
|
|
22
|
+
ws: true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
build: {
|
|
27
|
+
outDir: 'dist',
|
|
28
|
+
emptyOutDir: true,
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Static file serving for bundled React UI assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter
|
|
9
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_static_router() -> APIRouter | None:
|
|
18
|
+
"""Create router for UI static files. Returns None if dist/ missing."""
|
|
19
|
+
if not _FRONTEND_DIR.exists():
|
|
20
|
+
logger.warning("Frontend dist not found at %s", _FRONTEND_DIR)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
router = APIRouter(tags=["stemtrace-ui"])
|
|
24
|
+
router.mount(
|
|
25
|
+
"/assets",
|
|
26
|
+
StaticFiles(directory=_FRONTEND_DIR / "assets"),
|
|
27
|
+
name="stemtrace-assets",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@router.get("/", response_class=HTMLResponse)
|
|
31
|
+
async def serve_index() -> HTMLResponse:
|
|
32
|
+
"""Serve the main index.html page."""
|
|
33
|
+
index_path = _FRONTEND_DIR / "index.html"
|
|
34
|
+
if not index_path.exists():
|
|
35
|
+
return HTMLResponse("<h1>UI not built</h1>", status_code=503)
|
|
36
|
+
return HTMLResponse(index_path.read_text())
|
|
37
|
+
|
|
38
|
+
@router.get("/{path:path}", response_model=None)
|
|
39
|
+
async def serve_spa(path: str) -> FileResponse | HTMLResponse:
|
|
40
|
+
"""Serve static files or fall back to index.html for SPA routing."""
|
|
41
|
+
file_path = _FRONTEND_DIR / path
|
|
42
|
+
if file_path.exists() and file_path.is_file():
|
|
43
|
+
return FileResponse(file_path)
|
|
44
|
+
|
|
45
|
+
index_path = _FRONTEND_DIR / "index.html"
|
|
46
|
+
if index_path.exists():
|
|
47
|
+
return HTMLResponse(index_path.read_text())
|
|
48
|
+
|
|
49
|
+
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
|
50
|
+
|
|
51
|
+
return router
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_ui_available() -> bool:
|
|
55
|
+
"""Check if built UI assets exist."""
|
|
56
|
+
return (_FRONTEND_DIR / "index.html").exists()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""WebSocket connection manager for real-time event broadcasting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastapi import WebSocket
|
|
12
|
+
|
|
13
|
+
from stemtrace.core.events import TaskEvent
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSocketManager:
|
|
19
|
+
"""Thread-safe WebSocket manager that broadcasts events to connected clients."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
"""Initialize WebSocket manager with empty connection set."""
|
|
23
|
+
self._connections: set[WebSocket] = set()
|
|
24
|
+
self._queue: asyncio.Queue[TaskEvent] = asyncio.Queue()
|
|
25
|
+
self._broadcast_task: asyncio.Task[None] | None = None
|
|
26
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def connection_count(self) -> int:
|
|
30
|
+
"""Number of active connections."""
|
|
31
|
+
return len(self._connections)
|
|
32
|
+
|
|
33
|
+
async def connect(self, websocket: WebSocket) -> None:
|
|
34
|
+
"""Accept and register a WebSocket."""
|
|
35
|
+
await websocket.accept()
|
|
36
|
+
self._connections.add(websocket)
|
|
37
|
+
logger.debug("WebSocket connected, total: %d", len(self._connections))
|
|
38
|
+
|
|
39
|
+
def disconnect(self, websocket: WebSocket) -> None:
|
|
40
|
+
"""Unregister a WebSocket."""
|
|
41
|
+
self._connections.discard(websocket)
|
|
42
|
+
logger.debug("WebSocket disconnected, total: %d", len(self._connections))
|
|
43
|
+
|
|
44
|
+
def queue_event(self, event: TaskEvent) -> None:
|
|
45
|
+
"""Queue event for broadcast. Thread-safe, called from consumer thread."""
|
|
46
|
+
if self._loop is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
with contextlib.suppress(RuntimeError):
|
|
50
|
+
self._loop.call_soon_threadsafe(lambda: self._queue.put_nowait(event))
|
|
51
|
+
|
|
52
|
+
async def broadcast(self, event: TaskEvent) -> None:
|
|
53
|
+
"""Send event to all connected clients."""
|
|
54
|
+
if not self._connections:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
message = event.model_dump_json()
|
|
58
|
+
disconnected: list[WebSocket] = []
|
|
59
|
+
|
|
60
|
+
for websocket in self._connections.copy():
|
|
61
|
+
try:
|
|
62
|
+
await websocket.send_text(message)
|
|
63
|
+
except Exception:
|
|
64
|
+
disconnected.append(websocket)
|
|
65
|
+
|
|
66
|
+
for websocket in disconnected:
|
|
67
|
+
self.disconnect(websocket)
|
|
68
|
+
|
|
69
|
+
async def listen(self, websocket: WebSocket) -> None:
|
|
70
|
+
"""Block until client disconnects."""
|
|
71
|
+
with contextlib.suppress(Exception):
|
|
72
|
+
while True:
|
|
73
|
+
await websocket.receive_text()
|
|
74
|
+
|
|
75
|
+
async def start_broadcast_loop(self) -> None:
|
|
76
|
+
"""Start background task. Call during lifespan startup."""
|
|
77
|
+
self._loop = asyncio.get_running_loop()
|
|
78
|
+
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
|
79
|
+
logger.debug("WebSocket broadcast loop started")
|
|
80
|
+
|
|
81
|
+
async def stop_broadcast_loop(self) -> None:
|
|
82
|
+
"""Stop background task. Call during lifespan shutdown."""
|
|
83
|
+
if self._broadcast_task is not None:
|
|
84
|
+
self._broadcast_task.cancel()
|
|
85
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
86
|
+
await self._broadcast_task
|
|
87
|
+
self._broadcast_task = None
|
|
88
|
+
self._loop = None
|
|
89
|
+
logger.debug("WebSocket broadcast loop stopped")
|
|
90
|
+
|
|
91
|
+
async def _broadcast_loop(self) -> None:
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
event = await self._queue.get()
|
|
95
|
+
await self.broadcast(event)
|
|
96
|
+
except asyncio.CancelledError:
|
|
97
|
+
break
|
|
98
|
+
except Exception:
|
|
99
|
+
logger.exception("Error in broadcast loop")
|