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,136 @@
|
|
|
1
|
+
"""CLI entry point for stemtrace server."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="stemtrace",
|
|
9
|
+
help="Celery task flow visualizer",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def server(
|
|
16
|
+
broker_url: Annotated[
|
|
17
|
+
str,
|
|
18
|
+
typer.Option(
|
|
19
|
+
"--broker-url",
|
|
20
|
+
"-b",
|
|
21
|
+
envvar="CELERY_FLOW_BROKER_URL",
|
|
22
|
+
help="Broker URL (default: redis://localhost:6379/0)",
|
|
23
|
+
),
|
|
24
|
+
] = "redis://localhost:6379/0",
|
|
25
|
+
host: Annotated[
|
|
26
|
+
str,
|
|
27
|
+
typer.Option("--host", "-h", help="Host to bind to"),
|
|
28
|
+
] = "127.0.0.1",
|
|
29
|
+
port: Annotated[
|
|
30
|
+
int,
|
|
31
|
+
typer.Option("--port", "-p", help="Port to bind to"),
|
|
32
|
+
] = 8000,
|
|
33
|
+
reload: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option("--reload", help="Enable auto-reload (development)"),
|
|
36
|
+
] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Start the stemtrace web server with embedded consumer."""
|
|
39
|
+
import uvicorn
|
|
40
|
+
from fastapi import FastAPI
|
|
41
|
+
|
|
42
|
+
from stemtrace.server.fastapi.extension import StemtraceExtension
|
|
43
|
+
|
|
44
|
+
typer.echo(f"Starting stemtrace server on {host}:{port}")
|
|
45
|
+
typer.echo(f"Broker: {broker_url}")
|
|
46
|
+
|
|
47
|
+
from stemtrace.server.ui.static import get_static_router
|
|
48
|
+
|
|
49
|
+
# Create extension without UI (we'll serve UI at root separately)
|
|
50
|
+
extension = StemtraceExtension(broker_url=broker_url, serve_ui=False)
|
|
51
|
+
fastapi_app = FastAPI(
|
|
52
|
+
title="stemtrace",
|
|
53
|
+
lifespan=extension.lifespan,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# API and WebSocket at /stemtrace (frontend expects this path)
|
|
57
|
+
fastapi_app.include_router(extension.router, prefix="/stemtrace")
|
|
58
|
+
|
|
59
|
+
# UI at root for standalone CLI
|
|
60
|
+
ui_router = get_static_router()
|
|
61
|
+
if ui_router is not None:
|
|
62
|
+
fastapi_app.include_router(ui_router)
|
|
63
|
+
|
|
64
|
+
uvicorn.run(fastapi_app, host=host, port=port, reload=reload)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def consume(
|
|
69
|
+
broker_url: Annotated[
|
|
70
|
+
str,
|
|
71
|
+
typer.Option(
|
|
72
|
+
"--broker-url",
|
|
73
|
+
"-b",
|
|
74
|
+
envvar="CELERY_FLOW_BROKER_URL",
|
|
75
|
+
help="Broker URL (default: redis://localhost:6379/0)",
|
|
76
|
+
),
|
|
77
|
+
] = "redis://localhost:6379/0",
|
|
78
|
+
prefix: Annotated[
|
|
79
|
+
str,
|
|
80
|
+
typer.Option("--prefix", help="Stream key prefix"),
|
|
81
|
+
] = "stemtrace",
|
|
82
|
+
ttl: Annotated[
|
|
83
|
+
int,
|
|
84
|
+
typer.Option("--ttl", help="Event TTL in seconds"),
|
|
85
|
+
] = 86400,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Run the event consumer standalone (for external processing)."""
|
|
88
|
+
import signal
|
|
89
|
+
import sys
|
|
90
|
+
|
|
91
|
+
from stemtrace.server.consumer import EventConsumer
|
|
92
|
+
from stemtrace.server.store import GraphStore
|
|
93
|
+
|
|
94
|
+
typer.echo("Starting stemtrace consumer (standalone mode)")
|
|
95
|
+
typer.echo(f"Broker: {broker_url}")
|
|
96
|
+
|
|
97
|
+
store = GraphStore()
|
|
98
|
+
consumer = EventConsumer(broker_url, store, prefix=prefix, ttl=ttl)
|
|
99
|
+
|
|
100
|
+
def handle_signal(_signum: int, _frame: object) -> None:
|
|
101
|
+
"""Handle shutdown signals gracefully."""
|
|
102
|
+
typer.echo("\nShutting down consumer...")
|
|
103
|
+
consumer.stop()
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
|
|
106
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
107
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
108
|
+
|
|
109
|
+
consumer.start()
|
|
110
|
+
typer.echo("Consumer running. Press Ctrl+C to stop.")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
signal.pause()
|
|
114
|
+
except AttributeError:
|
|
115
|
+
# Windows doesn't have signal.pause
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
while consumer.is_running:
|
|
119
|
+
time.sleep(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def version() -> None:
|
|
124
|
+
"""Show version information."""
|
|
125
|
+
from stemtrace import __version__
|
|
126
|
+
|
|
127
|
+
typer.echo(f"stemtrace {__version__}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> None:
|
|
131
|
+
"""CLI entry point."""
|
|
132
|
+
app()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""API module - REST and WebSocket endpoints."""
|
|
2
|
+
|
|
3
|
+
from stemtrace.server.api.routes import create_api_router
|
|
4
|
+
from stemtrace.server.api.schemas import (
|
|
5
|
+
ErrorResponse,
|
|
6
|
+
GraphListResponse,
|
|
7
|
+
GraphNodeResponse,
|
|
8
|
+
GraphResponse,
|
|
9
|
+
HealthResponse,
|
|
10
|
+
TaskDetailResponse,
|
|
11
|
+
TaskEventResponse,
|
|
12
|
+
TaskListResponse,
|
|
13
|
+
TaskNodeResponse,
|
|
14
|
+
)
|
|
15
|
+
from stemtrace.server.api.websocket import create_websocket_router
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ErrorResponse",
|
|
19
|
+
"GraphListResponse",
|
|
20
|
+
"GraphNodeResponse",
|
|
21
|
+
"GraphResponse",
|
|
22
|
+
"HealthResponse",
|
|
23
|
+
"TaskDetailResponse",
|
|
24
|
+
"TaskEventResponse",
|
|
25
|
+
"TaskListResponse",
|
|
26
|
+
"TaskNodeResponse",
|
|
27
|
+
"create_api_router",
|
|
28
|
+
"create_websocket_router",
|
|
29
|
+
]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""REST API endpoints for stemtrace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from datetime import datetime # noqa: TC003 (FastAPI needs this at runtime)
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
10
|
+
|
|
11
|
+
from stemtrace.server.api.schemas import (
|
|
12
|
+
ErrorResponse,
|
|
13
|
+
GraphListResponse,
|
|
14
|
+
GraphNodeResponse,
|
|
15
|
+
GraphResponse,
|
|
16
|
+
HealthResponse,
|
|
17
|
+
RegisteredTaskResponse,
|
|
18
|
+
TaskDetailResponse,
|
|
19
|
+
TaskEventResponse,
|
|
20
|
+
TaskListResponse,
|
|
21
|
+
TaskNodeResponse,
|
|
22
|
+
TaskRegistryResponse,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from stemtrace.core.events import TaskState
|
|
27
|
+
from stemtrace.core.graph import TaskNode
|
|
28
|
+
from stemtrace.server.consumer import AsyncEventConsumer
|
|
29
|
+
from stemtrace.server.store import GraphStore
|
|
30
|
+
from stemtrace.server.websocket import WebSocketManager
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _node_to_response(node: TaskNode) -> TaskNodeResponse:
|
|
34
|
+
first_seen = node.events[0].timestamp if node.events else None
|
|
35
|
+
last_updated = node.events[-1].timestamp if node.events else None
|
|
36
|
+
|
|
37
|
+
duration_ms = None
|
|
38
|
+
if first_seen and last_updated and first_seen != last_updated:
|
|
39
|
+
duration_ms = int((last_updated - first_seen).total_seconds() * 1000)
|
|
40
|
+
|
|
41
|
+
return TaskNodeResponse(
|
|
42
|
+
task_id=node.task_id,
|
|
43
|
+
name=node.name,
|
|
44
|
+
state=node.state,
|
|
45
|
+
node_type=node.node_type,
|
|
46
|
+
group_id=node.group_id,
|
|
47
|
+
chord_id=node.chord_id,
|
|
48
|
+
parent_id=node.parent_id,
|
|
49
|
+
children=node.children,
|
|
50
|
+
events=[TaskEventResponse.model_validate(e) for e in node.events],
|
|
51
|
+
first_seen=first_seen,
|
|
52
|
+
last_updated=last_updated,
|
|
53
|
+
duration_ms=duration_ms,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _node_to_graph_response(
|
|
58
|
+
node: TaskNode,
|
|
59
|
+
all_nodes: dict[str, TaskNode] | None = None,
|
|
60
|
+
) -> GraphNodeResponse:
|
|
61
|
+
first_seen = node.events[0].timestamp if node.events else None
|
|
62
|
+
last_updated = node.events[-1].timestamp if node.events else None
|
|
63
|
+
|
|
64
|
+
# For synthetic nodes (GROUP/CHORD), compute timing from children
|
|
65
|
+
if not node.events and node.children and all_nodes:
|
|
66
|
+
child_first_seen: list[datetime] = []
|
|
67
|
+
child_last_updated: list[datetime] = []
|
|
68
|
+
for child_id in node.children:
|
|
69
|
+
child = all_nodes.get(child_id)
|
|
70
|
+
if child and child.events:
|
|
71
|
+
child_first_seen.append(child.events[0].timestamp)
|
|
72
|
+
child_last_updated.append(child.events[-1].timestamp)
|
|
73
|
+
if child_first_seen:
|
|
74
|
+
first_seen = min(child_first_seen)
|
|
75
|
+
if child_last_updated:
|
|
76
|
+
last_updated = max(child_last_updated)
|
|
77
|
+
|
|
78
|
+
duration_ms = None
|
|
79
|
+
if first_seen and last_updated and first_seen != last_updated:
|
|
80
|
+
duration_ms = int((last_updated - first_seen).total_seconds() * 1000)
|
|
81
|
+
|
|
82
|
+
return GraphNodeResponse(
|
|
83
|
+
task_id=node.task_id,
|
|
84
|
+
name=node.name,
|
|
85
|
+
state=node.state,
|
|
86
|
+
node_type=node.node_type,
|
|
87
|
+
group_id=node.group_id,
|
|
88
|
+
chord_id=node.chord_id,
|
|
89
|
+
parent_id=node.parent_id,
|
|
90
|
+
children=node.children,
|
|
91
|
+
duration_ms=duration_ms,
|
|
92
|
+
first_seen=first_seen,
|
|
93
|
+
last_updated=last_updated,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_api_router(
|
|
98
|
+
store: GraphStore,
|
|
99
|
+
consumer: AsyncEventConsumer | None = None,
|
|
100
|
+
ws_manager: WebSocketManager | None = None,
|
|
101
|
+
) -> APIRouter:
|
|
102
|
+
"""Create REST API router with task and graph endpoints."""
|
|
103
|
+
router = APIRouter(prefix="/api", tags=["stemtrace"])
|
|
104
|
+
|
|
105
|
+
@router.get("/health", response_model=HealthResponse)
|
|
106
|
+
async def health() -> HealthResponse:
|
|
107
|
+
"""Return server health status and connection counts."""
|
|
108
|
+
from stemtrace import __version__
|
|
109
|
+
|
|
110
|
+
return HealthResponse(
|
|
111
|
+
status="ok",
|
|
112
|
+
version=__version__,
|
|
113
|
+
consumer_running=consumer.is_running if consumer else False,
|
|
114
|
+
websocket_connections=ws_manager.connection_count if ws_manager else 0,
|
|
115
|
+
node_count=store.node_count,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@router.get(
|
|
119
|
+
"/tasks",
|
|
120
|
+
response_model=TaskListResponse,
|
|
121
|
+
responses={400: {"model": ErrorResponse}},
|
|
122
|
+
)
|
|
123
|
+
async def list_tasks(
|
|
124
|
+
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
|
125
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
126
|
+
state: Annotated[str | None, Query(description="Filter by task state")] = None,
|
|
127
|
+
name: Annotated[
|
|
128
|
+
str | None, Query(description="Filter by name substring")
|
|
129
|
+
] = None,
|
|
130
|
+
from_date: Annotated[
|
|
131
|
+
datetime | None, Query(description="Filter by start date (ISO format)")
|
|
132
|
+
] = None,
|
|
133
|
+
to_date: Annotated[
|
|
134
|
+
datetime | None, Query(description="Filter by end date (ISO format)")
|
|
135
|
+
] = None,
|
|
136
|
+
) -> TaskListResponse:
|
|
137
|
+
"""List tasks with optional filtering by state, name, and date range."""
|
|
138
|
+
from stemtrace.core.events import TaskState as TS
|
|
139
|
+
|
|
140
|
+
task_state: TaskState | None = None
|
|
141
|
+
if state is not None:
|
|
142
|
+
with contextlib.suppress(ValueError):
|
|
143
|
+
task_state = TS(state)
|
|
144
|
+
|
|
145
|
+
nodes, total = store.get_nodes(
|
|
146
|
+
limit=limit,
|
|
147
|
+
offset=offset,
|
|
148
|
+
state=task_state,
|
|
149
|
+
name_contains=name,
|
|
150
|
+
from_date=from_date,
|
|
151
|
+
to_date=to_date,
|
|
152
|
+
)
|
|
153
|
+
return TaskListResponse(
|
|
154
|
+
tasks=[_node_to_response(n) for n in nodes],
|
|
155
|
+
total=total,
|
|
156
|
+
limit=limit,
|
|
157
|
+
offset=offset,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@router.get(
|
|
161
|
+
"/tasks/registry",
|
|
162
|
+
response_model=TaskRegistryResponse,
|
|
163
|
+
)
|
|
164
|
+
async def get_task_registry(
|
|
165
|
+
query: Annotated[
|
|
166
|
+
str | None, Query(description="Filter by task name substring")
|
|
167
|
+
] = None,
|
|
168
|
+
) -> TaskRegistryResponse:
|
|
169
|
+
"""List all discovered task definitions."""
|
|
170
|
+
unique_names = store.get_unique_task_names()
|
|
171
|
+
|
|
172
|
+
tasks: list[RegisteredTaskResponse] = []
|
|
173
|
+
for name in sorted(unique_names):
|
|
174
|
+
if query and query.lower() not in name.lower():
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
parts = name.rsplit(".", 1)
|
|
178
|
+
module = parts[0] if len(parts) > 1 else None
|
|
179
|
+
|
|
180
|
+
tasks.append(
|
|
181
|
+
RegisteredTaskResponse(
|
|
182
|
+
name=name,
|
|
183
|
+
module=module,
|
|
184
|
+
signature=None,
|
|
185
|
+
docstring=None,
|
|
186
|
+
bound=False,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return TaskRegistryResponse(tasks=tasks, total=len(tasks))
|
|
191
|
+
|
|
192
|
+
@router.get(
|
|
193
|
+
"/tasks/{task_id}",
|
|
194
|
+
response_model=TaskDetailResponse,
|
|
195
|
+
responses={404: {"model": ErrorResponse}},
|
|
196
|
+
)
|
|
197
|
+
async def get_task(task_id: str) -> TaskDetailResponse:
|
|
198
|
+
"""Get detailed information for a specific task including children."""
|
|
199
|
+
node = store.get_node(task_id)
|
|
200
|
+
if node is None:
|
|
201
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
202
|
+
|
|
203
|
+
children = store.get_children(task_id)
|
|
204
|
+
return TaskDetailResponse(
|
|
205
|
+
task=_node_to_response(node),
|
|
206
|
+
children=[_node_to_response(c) for c in children],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@router.get(
|
|
210
|
+
"/tasks/{task_id}/children",
|
|
211
|
+
response_model=list[TaskNodeResponse],
|
|
212
|
+
responses={404: {"model": ErrorResponse}},
|
|
213
|
+
)
|
|
214
|
+
async def get_task_children(task_id: str) -> list[TaskNodeResponse]:
|
|
215
|
+
"""Get child tasks for a specific task."""
|
|
216
|
+
node = store.get_node(task_id)
|
|
217
|
+
if node is None:
|
|
218
|
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
|
219
|
+
|
|
220
|
+
children = store.get_children(task_id)
|
|
221
|
+
return [_node_to_response(c) for c in children]
|
|
222
|
+
|
|
223
|
+
@router.get("/graphs", response_model=GraphListResponse)
|
|
224
|
+
async def list_graphs(
|
|
225
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
|
226
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
227
|
+
from_date: Annotated[
|
|
228
|
+
datetime | None, Query(description="Filter by start date (ISO format)")
|
|
229
|
+
] = None,
|
|
230
|
+
to_date: Annotated[
|
|
231
|
+
datetime | None, Query(description="Filter by end date (ISO format)")
|
|
232
|
+
] = None,
|
|
233
|
+
) -> GraphListResponse:
|
|
234
|
+
"""List task execution graphs (root tasks) with pagination and date filtering."""
|
|
235
|
+
roots, total = store.get_root_nodes(
|
|
236
|
+
limit=limit,
|
|
237
|
+
offset=offset,
|
|
238
|
+
from_date=from_date,
|
|
239
|
+
to_date=to_date,
|
|
240
|
+
)
|
|
241
|
+
# Build nodes dict for synthetic node timing computation
|
|
242
|
+
all_nodes: dict[str, TaskNode] = {}
|
|
243
|
+
for root in roots:
|
|
244
|
+
all_nodes[root.task_id] = root
|
|
245
|
+
for child in store.get_children(root.task_id):
|
|
246
|
+
all_nodes[child.task_id] = child
|
|
247
|
+
return GraphListResponse(
|
|
248
|
+
graphs=[_node_to_graph_response(r, all_nodes) for r in roots],
|
|
249
|
+
total=total,
|
|
250
|
+
limit=limit,
|
|
251
|
+
offset=offset,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@router.get(
|
|
255
|
+
"/graphs/{root_id}",
|
|
256
|
+
response_model=GraphResponse,
|
|
257
|
+
responses={404: {"model": ErrorResponse}},
|
|
258
|
+
)
|
|
259
|
+
async def get_graph(root_id: str) -> GraphResponse:
|
|
260
|
+
"""Get complete task graph starting from a root task."""
|
|
261
|
+
graph = store.get_graph_from_root(root_id)
|
|
262
|
+
if not graph:
|
|
263
|
+
raise HTTPException(status_code=404, detail=f"Graph {root_id} not found")
|
|
264
|
+
|
|
265
|
+
return GraphResponse(
|
|
266
|
+
root_id=root_id,
|
|
267
|
+
nodes={tid: _node_to_graph_response(n, graph) for tid, n in graph.items()},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return router
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""API response schemas for REST endpoints."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from stemtrace.core.events import TaskState
|
|
9
|
+
from stemtrace.core.graph import NodeType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskEventResponse(BaseModel):
|
|
13
|
+
"""Single task event with full details."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(from_attributes=True)
|
|
16
|
+
|
|
17
|
+
task_id: str
|
|
18
|
+
name: str
|
|
19
|
+
state: TaskState
|
|
20
|
+
timestamp: datetime
|
|
21
|
+
parent_id: str | None = None
|
|
22
|
+
root_id: str | None = None
|
|
23
|
+
group_id: str | None = None
|
|
24
|
+
trace_id: str | None = None
|
|
25
|
+
retries: int = 0
|
|
26
|
+
|
|
27
|
+
# Enhanced event data
|
|
28
|
+
args: list[Any] | None = None
|
|
29
|
+
kwargs: dict[str, Any] | None = None
|
|
30
|
+
result: Any | None = None
|
|
31
|
+
exception: str | None = None
|
|
32
|
+
traceback: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TaskNodeResponse(BaseModel):
|
|
36
|
+
"""Task node with events and timing info."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(from_attributes=True)
|
|
39
|
+
|
|
40
|
+
task_id: str
|
|
41
|
+
name: str
|
|
42
|
+
state: TaskState
|
|
43
|
+
node_type: NodeType = NodeType.TASK
|
|
44
|
+
group_id: str | None = None
|
|
45
|
+
chord_id: str | None = None
|
|
46
|
+
parent_id: str | None = None
|
|
47
|
+
children: list[str] = Field(default_factory=list)
|
|
48
|
+
events: list[TaskEventResponse] = Field(default_factory=list)
|
|
49
|
+
first_seen: datetime | None = None
|
|
50
|
+
last_updated: datetime | None = None
|
|
51
|
+
duration_ms: int | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TaskListResponse(BaseModel):
|
|
55
|
+
"""Paginated list of tasks."""
|
|
56
|
+
|
|
57
|
+
tasks: list[TaskNodeResponse]
|
|
58
|
+
total: int
|
|
59
|
+
limit: int
|
|
60
|
+
offset: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TaskDetailResponse(BaseModel):
|
|
64
|
+
"""Task with its children."""
|
|
65
|
+
|
|
66
|
+
task: TaskNodeResponse
|
|
67
|
+
children: list[TaskNodeResponse] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GraphNodeResponse(BaseModel):
|
|
71
|
+
"""Minimal node for graph visualization."""
|
|
72
|
+
|
|
73
|
+
task_id: str
|
|
74
|
+
name: str
|
|
75
|
+
state: TaskState
|
|
76
|
+
node_type: NodeType = NodeType.TASK
|
|
77
|
+
group_id: str | None = None
|
|
78
|
+
chord_id: str | None = None
|
|
79
|
+
parent_id: str | None = None
|
|
80
|
+
children: list[str] = Field(default_factory=list)
|
|
81
|
+
duration_ms: int | None = None
|
|
82
|
+
first_seen: datetime | None = None
|
|
83
|
+
last_updated: datetime | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GraphResponse(BaseModel):
|
|
87
|
+
"""Full task graph from root."""
|
|
88
|
+
|
|
89
|
+
root_id: str
|
|
90
|
+
nodes: dict[str, GraphNodeResponse]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GraphListResponse(BaseModel):
|
|
94
|
+
"""Paginated list of root graphs."""
|
|
95
|
+
|
|
96
|
+
graphs: list[GraphNodeResponse]
|
|
97
|
+
total: int
|
|
98
|
+
limit: int
|
|
99
|
+
offset: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HealthResponse(BaseModel):
|
|
103
|
+
"""Health check status."""
|
|
104
|
+
|
|
105
|
+
status: str = "ok"
|
|
106
|
+
version: str
|
|
107
|
+
consumer_running: bool = False
|
|
108
|
+
websocket_connections: int = 0
|
|
109
|
+
node_count: int = 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ErrorResponse(BaseModel):
|
|
113
|
+
"""API error."""
|
|
114
|
+
|
|
115
|
+
detail: str
|
|
116
|
+
error_code: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Task Registry schemas
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RegisteredTaskResponse(BaseModel):
|
|
123
|
+
"""A registered Celery task definition."""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
signature: str | None = None
|
|
127
|
+
docstring: str | None = None
|
|
128
|
+
module: str | None = None
|
|
129
|
+
bound: bool = False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TaskRegistryResponse(BaseModel):
|
|
133
|
+
"""List of all registered tasks."""
|
|
134
|
+
|
|
135
|
+
tasks: list[RegisteredTaskResponse]
|
|
136
|
+
total: int
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""WebSocket endpoint for real-time event streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from stemtrace.server.websocket import WebSocketManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_websocket_router(ws_manager: WebSocketManager) -> APIRouter:
|
|
14
|
+
"""Create WebSocket router for real-time task events."""
|
|
15
|
+
router = APIRouter(tags=["stemtrace-websocket"])
|
|
16
|
+
|
|
17
|
+
@router.websocket("/ws")
|
|
18
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
19
|
+
"""Handle WebSocket connection for real-time task events."""
|
|
20
|
+
await ws_manager.connect(websocket)
|
|
21
|
+
try:
|
|
22
|
+
await ws_manager.listen(websocket)
|
|
23
|
+
except WebSocketDisconnect:
|
|
24
|
+
pass
|
|
25
|
+
finally:
|
|
26
|
+
ws_manager.disconnect(websocket)
|
|
27
|
+
|
|
28
|
+
return router
|