|
38 | 38 | agent_cli = typer.Typer(name="agent", help="Agent management", no_args_is_help=True) |
39 | 39 |
|
40 | 40 |
|
| 41 | +def sparkline(values: list[int | float], max_width: int = 50) -> str: |
| 42 | + """Generate Unicode sparkline from values, downsampling if needed.""" |
| 43 | + if not values: |
| 44 | + return "" |
| 45 | + |
| 46 | + # Downsample if too many values |
| 47 | + if len(values) > max_width: |
| 48 | + bucket_size = len(values) / max_width |
| 49 | + downsampled = [] |
| 50 | + for i in range(max_width): |
| 51 | + start = int(i * bucket_size) |
| 52 | + end = int((i + 1) * bucket_size) |
| 53 | + bucket = values[start:end] |
| 54 | + downsampled.append(sum(bucket) / len(bucket) if bucket else 0) |
| 55 | + values = downsampled |
| 56 | + |
| 57 | + blocks = "▁▂▃▄▅▆▇█" |
| 58 | + lo, hi = min(values), max(values) |
| 59 | + if hi == lo: |
| 60 | + return blocks[4] * len(values) # flat line |
| 61 | + scale = (hi - lo) / 7 |
| 62 | + return "".join(blocks[min(7, int((v - lo) / scale))] for v in values) |
| 63 | + |
| 64 | + |
| 65 | +def format_bytes(b: int) -> str: |
| 66 | + """Format bytes as human-readable string.""" |
| 67 | + if b >= 1024 * 1024 * 1024: |
| 68 | + return f"{b / (1024 * 1024 * 1024):.1f}GB" |
| 69 | + if b >= 1024 * 1024: |
| 70 | + return f"{b / (1024 * 1024):.0f}MB" |
| 71 | + if b >= 1024: |
| 72 | + return f"{b / 1024:.0f}KB" |
| 73 | + return f"{b}B" |
| 74 | + |
| 75 | + |
| 76 | +def format_cpu(millicores: int) -> str: |
| 77 | + """Format CPU millicores as human-readable string.""" |
| 78 | + return f"{millicores / 1000:.2f} cores" |
| 79 | + |
| 80 | + |
41 | 81 | # ----- Agent Commands ----- |
42 | 82 |
|
43 | 83 |
|
@@ -292,6 +332,90 @@ async def sessions( |
292 | 332 | console.error("No target agent name provided") |
293 | 333 | return typer.Exit(1) |
294 | 334 |
|
| 335 | + # If session_id is specified, fetch single session with detailed metrics |
| 336 | + if session_id: |
| 337 | + with Live( |
| 338 | + console.status(f"[dim]Looking up session '{session_id}'[/dim]", spinner="dots") |
| 339 | + ) as live: |
| 340 | + data, error = await API.agent_session( |
| 341 | + agent_name=agent_name, session_id=session_id, org=org, live=live |
| 342 | + ) |
| 343 | + live.stop() |
| 344 | + |
| 345 | + if error: |
| 346 | + return typer.Exit() |
| 347 | + |
| 348 | + if not data: |
| 349 | + console.error(f"Session '{session_id}' not found") |
| 350 | + return typer.Exit() |
| 351 | + |
| 352 | + # Display detailed session view |
| 353 | + session_duration = format_duration(data.get("createdAt"), data.get("endedAt")) or "N/A" |
| 354 | + status = data.get("completionStatus", "") |
| 355 | + if data.get("endedAt"): |
| 356 | + status_display = "[red]Error (500)[/red]" if status == "500" else "Complete" |
| 357 | + else: |
| 358 | + status_display = "[yellow]Active[/yellow]" |
| 359 | + |
| 360 | + # Build session info panel |
| 361 | + info_lines = [ |
| 362 | + f"[bold]Session ID:[/bold] {data['sessionId']}", |
| 363 | + f"[bold]Status:[/bold] {status_display}" |
| 364 | + + (f" ({status})" if status and status != "500" else ""), |
| 365 | + f"[bold]Duration:[/bold] {session_duration}", |
| 366 | + f"[bold]Created:[/bold] {format_timestamp(data.get('createdAt'))}", |
| 367 | + f"[bold]Ended:[/bold] {format_timestamp(data.get('endedAt')) if data.get('endedAt') else '[dim]N/A[/dim]'}", |
| 368 | + f"[bold]Bot Start:[/bold] {data.get('botStartSeconds')}s" |
| 369 | + if data.get("botStartSeconds") is not None |
| 370 | + else "[bold]Bot Start:[/bold] [dim]N/A[/dim]", |
| 371 | + f"[bold]Cold Start:[/bold] {'[red]Yes[/red]' if data.get('coldStart') else 'No'}", |
| 372 | + ] |
| 373 | + |
| 374 | + # Add resource metrics if available |
| 375 | + metrics = data.get("resourceMetrics") |
| 376 | + if metrics: |
| 377 | + timeseries = metrics.get("timeseries", []) |
| 378 | + sample_count = metrics.get("sampleCount", 0) |
| 379 | + |
| 380 | + # Calculate duration from timeseries |
| 381 | + if len(timeseries) >= 2: |
| 382 | + ts_duration = timeseries[-1].get("t", 0) - timeseries[0].get("t", 0) |
| 383 | + ts_duration_str = f"{ts_duration}s" |
| 384 | + else: |
| 385 | + ts_duration_str = "N/A" |
| 386 | + |
| 387 | + info_lines.append("") |
| 388 | + info_lines.append( |
| 389 | + f"[bold]Resource Metrics[/bold] ({sample_count} samples over {ts_duration_str}):" |
| 390 | + ) |
| 391 | + |
| 392 | + # CPU sparkline and percentiles |
| 393 | + cpu_values = [s.get("c", 0) for s in timeseries] |
| 394 | + cpu_spark = sparkline(cpu_values) if cpu_values else "" |
| 395 | + cpu_p50 = metrics.get("cpuMillicoresP50", 0) |
| 396 | + cpu_p99 = metrics.get("cpuMillicoresP99", 0) |
| 397 | + info_lines.append( |
| 398 | + f" CPU: {cpu_spark} p50: {format_cpu(cpu_p50)} p99: {format_cpu(cpu_p99)}" |
| 399 | + ) |
| 400 | + |
| 401 | + # Memory sparkline and percentiles |
| 402 | + mem_values = [s.get("m", 0) for s in timeseries] |
| 403 | + mem_spark = sparkline(mem_values) if mem_values else "" |
| 404 | + mem_p50 = int(metrics.get("memoryBytesP50", 0)) |
| 405 | + mem_p99 = int(metrics.get("memoryBytesP99", 0)) |
| 406 | + info_lines.append( |
| 407 | + f" Memory: {mem_spark} p50: {format_bytes(mem_p50)} p99: {format_bytes(mem_p99)}" |
| 408 | + ) |
| 409 | + |
| 410 | + console.success( |
| 411 | + Panel( |
| 412 | + "\n".join(info_lines), |
| 413 | + title=f"Session details for agent [bold]{agent_name}[/bold] [dim]({org})[/dim]", |
| 414 | + title_align="left", |
| 415 | + ), |
| 416 | + ) |
| 417 | + return |
| 418 | + |
295 | 419 | with Live( |
296 | 420 | console.status(f"[dim]Looking up agent with name '{agent_name}'[/dim]", spinner="dots") |
297 | 421 | ) as live: |
|
0 commit comments