uipath-dev 0.0.1__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.
- uipath/dev/__init__.py +329 -0
- uipath/dev/_demo/__init__.py +0 -0
- uipath/dev/_demo/mock_runtime.py +64 -0
- uipath/dev/_demo/run_dev_console.py +15 -0
- uipath/dev/_styles/terminal.tcss +261 -0
- uipath/dev/_utils/_exporter.py +119 -0
- uipath/dev/_utils/_logger.py +98 -0
- uipath/dev/components/details.py +453 -0
- uipath/dev/components/history.py +110 -0
- uipath/dev/components/json_input.py +27 -0
- uipath/dev/components/new.py +142 -0
- uipath/dev/models/execution.py +80 -0
- uipath/dev/models/messages.py +53 -0
- uipath/dev/py.typed +0 -0
- uipath_dev-0.0.1.dist-info/METADATA +59 -0
- uipath_dev-0.0.1.dist-info/RECORD +19 -0
- uipath_dev-0.0.1.dist-info/WHEEL +4 -0
- uipath_dev-0.0.1.dist-info/entry_points.txt +2 -0
- uipath_dev-0.0.1.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Callable, Sequence
|
|
4
|
+
|
|
5
|
+
from opentelemetry import trace
|
|
6
|
+
from opentelemetry.sdk.trace import Event, ReadableSpan
|
|
7
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
8
|
+
from opentelemetry.trace import StatusCode
|
|
9
|
+
|
|
10
|
+
from uipath.dev.models.messages import LogMessage, TraceMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunContextExporter(SpanExporter):
|
|
14
|
+
"""Custom trace exporter that sends traces and logs to CLI UI."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
on_trace: Callable[[TraceMessage], None],
|
|
19
|
+
on_log: Callable[[LogMessage], None],
|
|
20
|
+
):
|
|
21
|
+
self.on_trace = on_trace
|
|
22
|
+
self.on_log = on_log
|
|
23
|
+
self.logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
26
|
+
"""Export spans to CLI UI."""
|
|
27
|
+
try:
|
|
28
|
+
for span in spans:
|
|
29
|
+
self._export_span(span)
|
|
30
|
+
return SpanExportResult.SUCCESS
|
|
31
|
+
except Exception as e:
|
|
32
|
+
self.logger.error(f"Failed to export spans: {e}")
|
|
33
|
+
return SpanExportResult.FAILURE
|
|
34
|
+
|
|
35
|
+
def _export_span(self, span: ReadableSpan):
|
|
36
|
+
"""Export a single span to CLI UI."""
|
|
37
|
+
# Calculate duration
|
|
38
|
+
start_time = (
|
|
39
|
+
span.start_time / 1_000_000_000 if span.start_time is not None else 0
|
|
40
|
+
)
|
|
41
|
+
end_time = span.end_time / 1_000_000_000 if span.end_time is not None else None
|
|
42
|
+
duration_ms = (end_time - start_time) * 1000 if end_time else None
|
|
43
|
+
|
|
44
|
+
# Determine status
|
|
45
|
+
if span.status.status_code == StatusCode.ERROR:
|
|
46
|
+
status = "failed"
|
|
47
|
+
elif end_time:
|
|
48
|
+
status = "completed"
|
|
49
|
+
else:
|
|
50
|
+
status = "running"
|
|
51
|
+
|
|
52
|
+
# Extract span context information
|
|
53
|
+
span_context = span.get_span_context()
|
|
54
|
+
|
|
55
|
+
# Convert span IDs to string format (they're usually int64)
|
|
56
|
+
span_id = f"{span_context.span_id:016x}" # 16-char hex string
|
|
57
|
+
trace_id = f"{span_context.trace_id:032x}" # 32-char hex string
|
|
58
|
+
|
|
59
|
+
run_id = span.attributes.get("execution.id") if span.attributes else None
|
|
60
|
+
run_id_val = str(run_id) if run_id is not None else None
|
|
61
|
+
|
|
62
|
+
if run_id_val is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Get parent span ID if available
|
|
66
|
+
parent_span_id = None
|
|
67
|
+
if hasattr(span, "parent") and span.parent:
|
|
68
|
+
parent_span_id = f"{span.parent.span_id:016x}"
|
|
69
|
+
|
|
70
|
+
# Create trace message with all required fields
|
|
71
|
+
trace_msg = TraceMessage(
|
|
72
|
+
run_id=run_id_val,
|
|
73
|
+
span_name=span.name,
|
|
74
|
+
span_id=span_id,
|
|
75
|
+
parent_span_id=parent_span_id,
|
|
76
|
+
trace_id=trace_id,
|
|
77
|
+
status=status,
|
|
78
|
+
duration_ms=duration_ms,
|
|
79
|
+
timestamp=datetime.fromtimestamp(start_time),
|
|
80
|
+
attributes=dict(span.attributes) if span.attributes else {},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Send to UI
|
|
84
|
+
self.on_trace(trace_msg)
|
|
85
|
+
|
|
86
|
+
# Also send logs if there are events
|
|
87
|
+
if hasattr(span, "events") and span.events:
|
|
88
|
+
for event in span.events:
|
|
89
|
+
log_level = self._determine_log_level(event, span.status)
|
|
90
|
+
log_msg = LogMessage(
|
|
91
|
+
run_id=run_id_val,
|
|
92
|
+
level=log_level,
|
|
93
|
+
message=event.name,
|
|
94
|
+
timestamp=datetime.fromtimestamp(event.timestamp / 1_000_000_000),
|
|
95
|
+
)
|
|
96
|
+
self.on_log(log_msg)
|
|
97
|
+
|
|
98
|
+
def _determine_log_level(self, event: Event, span_status: trace.Status) -> str:
|
|
99
|
+
"""Determine log level from span event."""
|
|
100
|
+
event_name = event.name.lower()
|
|
101
|
+
|
|
102
|
+
if span_status.status_code == StatusCode.ERROR:
|
|
103
|
+
return "ERROR"
|
|
104
|
+
elif "error" in event_name or "exception" in event_name:
|
|
105
|
+
return "ERROR"
|
|
106
|
+
elif "warn" in event_name:
|
|
107
|
+
return "WARNING"
|
|
108
|
+
elif "debug" in event_name:
|
|
109
|
+
return "DEBUG"
|
|
110
|
+
else:
|
|
111
|
+
return "INFO"
|
|
112
|
+
|
|
113
|
+
def shutdown(self) -> None:
|
|
114
|
+
"""Shutdown the exporter."""
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
118
|
+
"""Force flush any pending spans."""
|
|
119
|
+
return True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Callable, Pattern
|
|
9
|
+
|
|
10
|
+
from uipath.dev.models.messages import LogMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunContextLogHandler(logging.Handler):
|
|
14
|
+
"""Custom log handler that sends logs to CLI UI."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
run_id: str,
|
|
19
|
+
callback: Callable[[LogMessage], None],
|
|
20
|
+
):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.run_id = run_id
|
|
23
|
+
self.callback = callback
|
|
24
|
+
|
|
25
|
+
def emit(self, record: logging.LogRecord):
|
|
26
|
+
"""Emit a log record to CLI UI."""
|
|
27
|
+
try:
|
|
28
|
+
log_msg = LogMessage(
|
|
29
|
+
run_id=self.run_id,
|
|
30
|
+
level=record.levelname,
|
|
31
|
+
message=self.format(record),
|
|
32
|
+
timestamp=datetime.fromtimestamp(record.created),
|
|
33
|
+
)
|
|
34
|
+
self.callback(log_msg)
|
|
35
|
+
except Exception:
|
|
36
|
+
# Don't let logging errors crash the app
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# A dispatcher is a callable that accepts (level, message) pairs
|
|
41
|
+
DispatchLog = Callable[[str, str], None]
|
|
42
|
+
|
|
43
|
+
LEVEL_PATTERNS: list[tuple[str, Pattern[str]]] = [
|
|
44
|
+
("DEBUG", re.compile(r"^(DEBUG)[:\s-]+", re.I)),
|
|
45
|
+
("INFO", re.compile(r"^(INFO)[:\s-]+", re.I)),
|
|
46
|
+
("WARN", re.compile(r"^(WARNING|WARN)[:\s-]+", re.I)),
|
|
47
|
+
("ERROR", re.compile(r"^(ERROR|ERRO)[:\s-]+", re.I)),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def patch_textual_stderr(dispatch_log: DispatchLog) -> int:
|
|
52
|
+
"""Redirect subprocess stderr into a provided dispatcher.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dispatch_log: Callable invoked with (level, message) for each stderr line.
|
|
56
|
+
This will be called from a background thread, so the caller
|
|
57
|
+
should use `App.call_from_thread` or equivalent.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
int: The write file descriptor for stderr (pass to subprocesses).
|
|
61
|
+
"""
|
|
62
|
+
from textual.app import _PrintCapture
|
|
63
|
+
|
|
64
|
+
read_fd, write_fd = os.pipe()
|
|
65
|
+
|
|
66
|
+
# Patch fileno() so subprocesses can write to our pipe
|
|
67
|
+
_PrintCapture.fileno = lambda self: write_fd # type: ignore[method-assign]
|
|
68
|
+
|
|
69
|
+
def read_stderr_pipe() -> None:
|
|
70
|
+
with os.fdopen(read_fd, "r", buffering=1) as pipe_reader:
|
|
71
|
+
try:
|
|
72
|
+
for raw in pipe_reader:
|
|
73
|
+
text = raw.rstrip()
|
|
74
|
+
level: str = "ERROR"
|
|
75
|
+
message: str = text
|
|
76
|
+
|
|
77
|
+
# Try to parse a known level prefix
|
|
78
|
+
for lvl, pattern in LEVEL_PATTERNS:
|
|
79
|
+
m = pattern.match(text)
|
|
80
|
+
if m:
|
|
81
|
+
level = lvl
|
|
82
|
+
message = text[m.end() :]
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
dispatch_log(level, message)
|
|
86
|
+
|
|
87
|
+
except Exception:
|
|
88
|
+
# Never raise from thread
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
thread = threading.Thread(
|
|
92
|
+
target=read_stderr_pipe,
|
|
93
|
+
daemon=True,
|
|
94
|
+
name="stderr-reader",
|
|
95
|
+
)
|
|
96
|
+
thread.start()
|
|
97
|
+
|
|
98
|
+
return write_fd
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Panel for displaying execution run details, traces, and logs."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
from textual.widgets import RichLog, TabbedContent, TabPane, Tree
|
|
9
|
+
from textual.widgets.tree import TreeNode
|
|
10
|
+
|
|
11
|
+
from uipath.dev.models.execution import ExecutionRun
|
|
12
|
+
from uipath.dev.models.messages import LogMessage, TraceMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpanDetailsDisplay(Container):
|
|
16
|
+
"""Widget to display details of a selected span."""
|
|
17
|
+
|
|
18
|
+
def compose(self) -> ComposeResult:
|
|
19
|
+
"""Compose the UI layout."""
|
|
20
|
+
yield RichLog(
|
|
21
|
+
id="span-details",
|
|
22
|
+
max_lines=1000,
|
|
23
|
+
highlight=True,
|
|
24
|
+
markup=True,
|
|
25
|
+
classes="detail-log",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def show_span_details(self, trace_msg: TraceMessage):
|
|
29
|
+
"""Display detailed information about a trace span."""
|
|
30
|
+
details_log = self.query_one("#span-details", RichLog)
|
|
31
|
+
details_log.clear()
|
|
32
|
+
|
|
33
|
+
details_log.write(f"[bold cyan]Span: {trace_msg.span_name}[/bold cyan]")
|
|
34
|
+
|
|
35
|
+
details_log.write("") # Empty line
|
|
36
|
+
|
|
37
|
+
# Status with color
|
|
38
|
+
color_map = {
|
|
39
|
+
"started": "blue",
|
|
40
|
+
"running": "yellow",
|
|
41
|
+
"completed": "green",
|
|
42
|
+
"failed": "red",
|
|
43
|
+
"error": "red",
|
|
44
|
+
}
|
|
45
|
+
color = color_map.get(trace_msg.status.lower(), "white")
|
|
46
|
+
details_log.write(f"Status: [{color}]{trace_msg.status.upper()}[/{color}]")
|
|
47
|
+
|
|
48
|
+
# Timestamps
|
|
49
|
+
details_log.write(
|
|
50
|
+
f"Started: [dim]{trace_msg.timestamp.strftime('%H:%M:%S.%f')[:-3]}[/dim]"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if trace_msg.duration_ms is not None:
|
|
54
|
+
details_log.write(
|
|
55
|
+
f"Duration: [yellow]{trace_msg.duration_ms:.2f}ms[/yellow]"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Additional attributes if available
|
|
59
|
+
if trace_msg.attributes:
|
|
60
|
+
details_log.write("")
|
|
61
|
+
details_log.write("[bold]Attributes:[/bold]")
|
|
62
|
+
for key, value in trace_msg.attributes.items():
|
|
63
|
+
details_log.write(f" {key}: {value}")
|
|
64
|
+
|
|
65
|
+
details_log.write("") # Empty line
|
|
66
|
+
|
|
67
|
+
# Format span details
|
|
68
|
+
details_log.write(f"[dim]Trace ID: {trace_msg.trace_id}[/dim]")
|
|
69
|
+
details_log.write(f"[dim]Span ID: {trace_msg.span_id}[/dim]")
|
|
70
|
+
details_log.write(f"[dim]Run ID: {trace_msg.run_id}[/dim]")
|
|
71
|
+
|
|
72
|
+
if trace_msg.parent_span_id:
|
|
73
|
+
details_log.write(f"[dim]Parent Span: {trace_msg.parent_span_id}[/dim]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RunDetailsPanel(Container):
|
|
77
|
+
"""Panel showing traces and logs for selected run with tabbed interface."""
|
|
78
|
+
|
|
79
|
+
current_run: reactive[Optional[ExecutionRun]] = reactive(None)
|
|
80
|
+
|
|
81
|
+
def __init__(self, **kwargs):
|
|
82
|
+
"""Initialize RunDetailsPanel."""
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self.span_tree_nodes = {} # Map span_id to tree nodes
|
|
85
|
+
self.current_run = None # Store reference to current run
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
"""Compose the UI layout."""
|
|
89
|
+
with TabbedContent():
|
|
90
|
+
# Run details tab
|
|
91
|
+
with TabPane("Details", id="run-tab"):
|
|
92
|
+
yield RichLog(
|
|
93
|
+
id="run-details-log",
|
|
94
|
+
max_lines=1000,
|
|
95
|
+
highlight=True,
|
|
96
|
+
markup=True,
|
|
97
|
+
classes="detail-log",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Traces tab
|
|
101
|
+
with TabPane("Traces", id="traces-tab"):
|
|
102
|
+
with Horizontal(classes="traces-content"):
|
|
103
|
+
# Left side - Span tree
|
|
104
|
+
with Vertical(
|
|
105
|
+
classes="spans-tree-section", id="spans-tree-container"
|
|
106
|
+
):
|
|
107
|
+
yield Tree("Trace", id="spans-tree", classes="spans-tree")
|
|
108
|
+
|
|
109
|
+
# Right side - Span details
|
|
110
|
+
with Vertical(classes="span-details-section"):
|
|
111
|
+
yield SpanDetailsDisplay(id="span-details-display")
|
|
112
|
+
|
|
113
|
+
# Logs tab
|
|
114
|
+
with TabPane("Logs", id="logs-tab"):
|
|
115
|
+
yield RichLog(
|
|
116
|
+
id="logs-log",
|
|
117
|
+
max_lines=1000,
|
|
118
|
+
highlight=True,
|
|
119
|
+
markup=True,
|
|
120
|
+
classes="detail-log",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def watch_current_run(
|
|
124
|
+
self, old_value: Optional[ExecutionRun], new_value: Optional[ExecutionRun]
|
|
125
|
+
):
|
|
126
|
+
"""Watch for changes to the current run."""
|
|
127
|
+
if new_value is not None:
|
|
128
|
+
if old_value != new_value:
|
|
129
|
+
self.current_run = new_value
|
|
130
|
+
self.show_run(new_value)
|
|
131
|
+
|
|
132
|
+
def update_run(self, run: ExecutionRun):
|
|
133
|
+
"""Update the displayed run information."""
|
|
134
|
+
self.current_run = run
|
|
135
|
+
|
|
136
|
+
def show_run(self, run: ExecutionRun):
|
|
137
|
+
"""Display traces and logs for a specific run."""
|
|
138
|
+
# Populate run details tab
|
|
139
|
+
self._show_run_details(run)
|
|
140
|
+
|
|
141
|
+
# Populate logs - convert string logs to display format
|
|
142
|
+
logs_log = self.query_one("#logs-log", RichLog)
|
|
143
|
+
logs_log.clear()
|
|
144
|
+
for log in run.logs:
|
|
145
|
+
self.add_log(log)
|
|
146
|
+
|
|
147
|
+
# Clear and rebuild traces tree using TraceMessage objects
|
|
148
|
+
self._rebuild_spans_tree()
|
|
149
|
+
|
|
150
|
+
def switch_tab(self, tab_id: str) -> None:
|
|
151
|
+
"""Switch to a specific tab by id (e.g. 'run-tab', 'traces-tab')."""
|
|
152
|
+
tabbed = self.query_one(TabbedContent)
|
|
153
|
+
tabbed.active = tab_id
|
|
154
|
+
|
|
155
|
+
def _flatten_values(self, value: object, prefix: str = "") -> list[str]:
|
|
156
|
+
"""Flatten nested dict/list structures into dot-notation paths."""
|
|
157
|
+
lines: list[str] = []
|
|
158
|
+
|
|
159
|
+
if value is None:
|
|
160
|
+
lines.append(f"{prefix}: [dim]—[/dim]" if prefix else "[dim]—[/dim]")
|
|
161
|
+
|
|
162
|
+
elif isinstance(value, dict):
|
|
163
|
+
if not value:
|
|
164
|
+
lines.append(f"{prefix}: {{}}" if prefix else "{}")
|
|
165
|
+
else:
|
|
166
|
+
for k, v in value.items():
|
|
167
|
+
new_prefix = f"{prefix}.{k}" if prefix else k
|
|
168
|
+
lines.extend(self._flatten_values(v, new_prefix))
|
|
169
|
+
|
|
170
|
+
elif isinstance(value, list):
|
|
171
|
+
if not value:
|
|
172
|
+
lines.append(f"{prefix}: []" if prefix else "[]")
|
|
173
|
+
else:
|
|
174
|
+
for i, item in enumerate(value):
|
|
175
|
+
new_prefix = f"{prefix}[{i}]"
|
|
176
|
+
lines.extend(self._flatten_values(item, new_prefix))
|
|
177
|
+
|
|
178
|
+
elif isinstance(value, str):
|
|
179
|
+
if prefix:
|
|
180
|
+
split_lines = value.splitlines()
|
|
181
|
+
if split_lines:
|
|
182
|
+
lines.append(f"{prefix}: {split_lines[0]}")
|
|
183
|
+
for line in split_lines[1:]:
|
|
184
|
+
lines.append(f"{' ' * 2}{line}")
|
|
185
|
+
else:
|
|
186
|
+
lines.extend(value.splitlines())
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
if prefix:
|
|
190
|
+
lines.append(f"{prefix}: {value}")
|
|
191
|
+
else:
|
|
192
|
+
lines.append(str(value))
|
|
193
|
+
|
|
194
|
+
return lines
|
|
195
|
+
|
|
196
|
+
def _write_block(
|
|
197
|
+
self, log: RichLog, title: str, data: object, style: str = "white"
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Pretty-print a block with flattened dot-notation paths."""
|
|
200
|
+
log.write(f"[bold {style}]{title.upper()}:[/bold {style}]")
|
|
201
|
+
log.write("[dim]" + "=" * 50 + "[/dim]")
|
|
202
|
+
|
|
203
|
+
for line in self._flatten_values(data):
|
|
204
|
+
log.write(line)
|
|
205
|
+
|
|
206
|
+
log.write("")
|
|
207
|
+
|
|
208
|
+
def _show_run_details(self, run: ExecutionRun):
|
|
209
|
+
"""Display detailed information about the run in the Details tab."""
|
|
210
|
+
run_details_log = self.query_one("#run-details-log", RichLog)
|
|
211
|
+
run_details_log.clear()
|
|
212
|
+
|
|
213
|
+
# Run header
|
|
214
|
+
run_details_log.write(f"[bold cyan]Run ID: {run.id}[/bold cyan]")
|
|
215
|
+
run_details_log.write("")
|
|
216
|
+
|
|
217
|
+
# Run status with color
|
|
218
|
+
status_color_map = {
|
|
219
|
+
"started": "blue",
|
|
220
|
+
"running": "yellow",
|
|
221
|
+
"completed": "green",
|
|
222
|
+
"failed": "red",
|
|
223
|
+
"error": "red",
|
|
224
|
+
}
|
|
225
|
+
status = getattr(run, "status", "unknown")
|
|
226
|
+
color = status_color_map.get(status.lower(), "white")
|
|
227
|
+
run_details_log.write(
|
|
228
|
+
f"[bold]Status:[/bold] [{color}]{status.upper()}[/{color}]"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Timestamps
|
|
232
|
+
if hasattr(run, "start_time") and run.start_time:
|
|
233
|
+
run_details_log.write(
|
|
234
|
+
f"[bold]Started:[/bold] [dim]{run.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if hasattr(run, "end_time") and run.end_time:
|
|
238
|
+
run_details_log.write(
|
|
239
|
+
f"[bold]Ended:[/bold] [dim]{run.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Duration
|
|
243
|
+
if hasattr(run, "duration_ms") and run.duration_ms is not None:
|
|
244
|
+
run_details_log.write(
|
|
245
|
+
f"[bold]Duration:[/bold] [yellow]{run.duration_ms:.2f}ms[/yellow]"
|
|
246
|
+
)
|
|
247
|
+
elif (
|
|
248
|
+
hasattr(run, "start_time")
|
|
249
|
+
and hasattr(run, "end_time")
|
|
250
|
+
and run.start_time
|
|
251
|
+
and run.end_time
|
|
252
|
+
):
|
|
253
|
+
duration = (run.end_time - run.start_time).total_seconds() * 1000
|
|
254
|
+
run_details_log.write(
|
|
255
|
+
f"[bold]Duration:[/bold] [yellow]{duration:.2f}ms[/yellow]"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
run_details_log.write("")
|
|
259
|
+
|
|
260
|
+
if hasattr(run, "input_data"):
|
|
261
|
+
self._write_block(run_details_log, "Input", run.input_data, style="green")
|
|
262
|
+
|
|
263
|
+
if hasattr(run, "resume_data") and run.resume_data:
|
|
264
|
+
self._write_block(run_details_log, "Resume", run.resume_data, style="green")
|
|
265
|
+
|
|
266
|
+
if hasattr(run, "output_data"):
|
|
267
|
+
self._write_block(
|
|
268
|
+
run_details_log, "Output", run.output_data, style="magenta"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Error section (if applicable)
|
|
272
|
+
if hasattr(run, "error") and run.error:
|
|
273
|
+
run_details_log.write("[bold red]ERROR:[/bold red]")
|
|
274
|
+
run_details_log.write("[dim]" + "=" * 50 + "[/dim]")
|
|
275
|
+
if run.error.code:
|
|
276
|
+
run_details_log.write(f"[red]Code: {run.error.code}[/red]")
|
|
277
|
+
run_details_log.write(f"[red]Title: {run.error.title}[/red]")
|
|
278
|
+
run_details_log.write(f"[red]\n{run.error.detail}[/red]")
|
|
279
|
+
run_details_log.write("")
|
|
280
|
+
|
|
281
|
+
def _rebuild_spans_tree(self):
|
|
282
|
+
"""Rebuild the spans tree from current run's traces."""
|
|
283
|
+
spans_tree = self.query_one("#spans-tree", Tree)
|
|
284
|
+
if spans_tree is None or spans_tree.root is None:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
spans_tree.root.remove_children()
|
|
288
|
+
|
|
289
|
+
# Only clear the node mapping since we're rebuilding the tree structure
|
|
290
|
+
self.span_tree_nodes.clear()
|
|
291
|
+
|
|
292
|
+
if not self.current_run or not self.current_run.traces:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# Build spans tree from TraceMessage objects
|
|
296
|
+
self._build_spans_tree(self.current_run.traces)
|
|
297
|
+
|
|
298
|
+
# Expand the root "Trace" node
|
|
299
|
+
spans_tree.root.expand()
|
|
300
|
+
|
|
301
|
+
def _build_spans_tree(self, trace_messages: list[TraceMessage]):
|
|
302
|
+
"""Build the spans tree from trace messages."""
|
|
303
|
+
spans_tree = self.query_one("#spans-tree", Tree)
|
|
304
|
+
root = spans_tree.root
|
|
305
|
+
|
|
306
|
+
# Filter out spans without parents (artificial root spans)
|
|
307
|
+
spans_by_id = {
|
|
308
|
+
msg.span_id: msg for msg in trace_messages if msg.parent_span_id is not None
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Build parent-to-children mapping once upfront
|
|
312
|
+
children_by_parent: dict[str, list[TraceMessage]] = {}
|
|
313
|
+
for msg in spans_by_id.values():
|
|
314
|
+
if msg.parent_span_id:
|
|
315
|
+
if msg.parent_span_id not in children_by_parent:
|
|
316
|
+
children_by_parent[msg.parent_span_id] = []
|
|
317
|
+
children_by_parent[msg.parent_span_id].append(msg)
|
|
318
|
+
|
|
319
|
+
# Find root spans (parent doesn't exist in our filtered data)
|
|
320
|
+
root_spans = [
|
|
321
|
+
msg
|
|
322
|
+
for msg in trace_messages
|
|
323
|
+
if msg.parent_span_id and msg.parent_span_id not in spans_by_id
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
# Build tree recursively for each root span
|
|
327
|
+
for root_span in sorted(root_spans, key=lambda x: x.timestamp):
|
|
328
|
+
self._add_span_with_children(root, root_span, children_by_parent)
|
|
329
|
+
|
|
330
|
+
def _add_span_with_children(
|
|
331
|
+
self,
|
|
332
|
+
parent_node: TreeNode[str],
|
|
333
|
+
trace_msg: TraceMessage,
|
|
334
|
+
children_by_parent: dict[str, list[TraceMessage]],
|
|
335
|
+
):
|
|
336
|
+
"""Recursively add a span and all its children."""
|
|
337
|
+
# Create the node for this span
|
|
338
|
+
color_map = {
|
|
339
|
+
"started": "🔵",
|
|
340
|
+
"running": "🟡",
|
|
341
|
+
"completed": "🟢",
|
|
342
|
+
"failed": "🔴",
|
|
343
|
+
"error": "🔴",
|
|
344
|
+
}
|
|
345
|
+
status_icon = color_map.get(trace_msg.status.lower(), "⚪")
|
|
346
|
+
duration_str = (
|
|
347
|
+
f" ({trace_msg.duration_ms:.1f}ms)" if trace_msg.duration_ms else ""
|
|
348
|
+
)
|
|
349
|
+
label = f"{status_icon} {trace_msg.span_name}{duration_str}"
|
|
350
|
+
|
|
351
|
+
node = parent_node.add(label)
|
|
352
|
+
node.data = trace_msg.span_id
|
|
353
|
+
self.span_tree_nodes[trace_msg.span_id] = node
|
|
354
|
+
node.expand()
|
|
355
|
+
|
|
356
|
+
# Get children from prebuilt mapping - O(1) lookup
|
|
357
|
+
children = children_by_parent.get(trace_msg.span_id, [])
|
|
358
|
+
for child in sorted(children, key=lambda x: x.timestamp):
|
|
359
|
+
self._add_span_with_children(node, child, children_by_parent)
|
|
360
|
+
|
|
361
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
|
|
362
|
+
"""Handle span selection in the tree."""
|
|
363
|
+
# Check if this is our spans tree
|
|
364
|
+
spans_tree = self.query_one("#spans-tree", Tree)
|
|
365
|
+
if event.control != spans_tree:
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# Get the selected span data
|
|
369
|
+
if hasattr(event.node, "data") and event.node.data:
|
|
370
|
+
span_id = event.node.data
|
|
371
|
+
# Find the trace in current_run.traces
|
|
372
|
+
trace_msg = None
|
|
373
|
+
if self.current_run:
|
|
374
|
+
for trace in self.current_run.traces:
|
|
375
|
+
if trace.span_id == span_id:
|
|
376
|
+
trace_msg = trace
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
if trace_msg:
|
|
380
|
+
# Show span details
|
|
381
|
+
span_details_display = self.query_one(
|
|
382
|
+
"#span-details-display", SpanDetailsDisplay
|
|
383
|
+
)
|
|
384
|
+
span_details_display.show_span_details(trace_msg)
|
|
385
|
+
|
|
386
|
+
def update_run_details(self, run: ExecutionRun):
|
|
387
|
+
"""Update run details if it matches the current run."""
|
|
388
|
+
if not self.current_run or run.id != self.current_run.id:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
self._show_run_details(run)
|
|
392
|
+
|
|
393
|
+
def add_trace(self, trace_msg: TraceMessage):
|
|
394
|
+
"""Add trace to current run if it matches."""
|
|
395
|
+
if not self.current_run or trace_msg.run_id != self.current_run.id:
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# Rebuild the tree to include new trace
|
|
399
|
+
self._rebuild_spans_tree()
|
|
400
|
+
|
|
401
|
+
def add_log(self, log_msg: LogMessage):
|
|
402
|
+
"""Add log to current run if it matches."""
|
|
403
|
+
if not self.current_run or log_msg.run_id != self.current_run.id:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
color_map = {
|
|
407
|
+
"DEBUG": "dim cyan",
|
|
408
|
+
"INFO": "blue",
|
|
409
|
+
"WARN": "yellow",
|
|
410
|
+
"WARNING": "yellow",
|
|
411
|
+
"ERROR": "red",
|
|
412
|
+
"CRITICAL": "bold red",
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
color = color_map.get(log_msg.level.upper(), "white")
|
|
416
|
+
timestamp_str = log_msg.timestamp.strftime("%H:%M:%S")
|
|
417
|
+
level_short = log_msg.level[:4].upper()
|
|
418
|
+
|
|
419
|
+
logs_log = self.query_one("#logs-log", RichLog)
|
|
420
|
+
if isinstance(log_msg.message, str):
|
|
421
|
+
log_text = (
|
|
422
|
+
f"[dim]{timestamp_str}[/dim] "
|
|
423
|
+
f"[{color}]{level_short}[/{color}] "
|
|
424
|
+
f"{log_msg.message}"
|
|
425
|
+
)
|
|
426
|
+
logs_log.write(log_text)
|
|
427
|
+
else:
|
|
428
|
+
logs_log.write(log_msg.message)
|
|
429
|
+
|
|
430
|
+
def clear_display(self):
|
|
431
|
+
"""Clear both traces and logs display."""
|
|
432
|
+
run_details_log = self.query_one("#run-details-log", RichLog)
|
|
433
|
+
logs_log = self.query_one("#logs-log", RichLog)
|
|
434
|
+
spans_tree = self.query_one("#spans-tree", Tree)
|
|
435
|
+
|
|
436
|
+
run_details_log.clear()
|
|
437
|
+
logs_log.clear()
|
|
438
|
+
spans_tree.clear()
|
|
439
|
+
|
|
440
|
+
self.current_run = None
|
|
441
|
+
self.span_tree_nodes.clear()
|
|
442
|
+
|
|
443
|
+
# Clear span details
|
|
444
|
+
span_details_display = self.query_one(
|
|
445
|
+
"#span-details-display", SpanDetailsDisplay
|
|
446
|
+
)
|
|
447
|
+
span_details_log = span_details_display.query_one("#span-details", RichLog)
|
|
448
|
+
span_details_log.clear()
|
|
449
|
+
|
|
450
|
+
def refresh_display(self):
|
|
451
|
+
"""Refresh the display with current run data."""
|
|
452
|
+
if self.current_run:
|
|
453
|
+
self.show_run(self.current_run)
|