|
2 | 2 |
|
3 | 3 | Manage experiment reports with generation, viewing, and listing capabilities. |
4 | 4 | Wraps the reporting subsystem (ExperimentReporter, StatusCollector) for CLI use. |
| 5 | +Includes timeline and artifact browsing subcommands. |
5 | 6 | """ |
6 | 7 |
|
| 8 | +import json |
7 | 9 | from datetime import datetime |
8 | 10 | from pathlib import Path |
9 | 11 | from typing import List, Tuple |
@@ -306,5 +308,317 @@ def show(_ctx, experiment_dir): |
306 | 308 | _success("Report displayed") |
307 | 309 |
|
308 | 310 |
|
| 311 | +# -- timeline helpers -- |
| 312 | + |
| 313 | + |
| 314 | +def _parse_time(value: str) -> datetime: |
| 315 | + """Parse a time string into a datetime. |
| 316 | +
|
| 317 | + Accepts ISO-8601 format (e.g. ``2025-01-15T10:00:00+00:00``) as well |
| 318 | + as a bare time like ``10:00:00`` (interpreted as today, UTC). |
| 319 | +
|
| 320 | + Args: |
| 321 | + value: Time string to parse. |
| 322 | +
|
| 323 | + Returns: |
| 324 | + Parsed datetime. |
| 325 | +
|
| 326 | + Raises: |
| 327 | + click.BadParameter: If the string cannot be parsed. |
| 328 | + """ |
| 329 | + try: |
| 330 | + return datetime.fromisoformat(value) |
| 331 | + except ValueError: |
| 332 | + pass |
| 333 | + try: |
| 334 | + from datetime import timezone |
| 335 | + |
| 336 | + t = datetime.strptime(value, "%H:%M:%S").time() |
| 337 | + return datetime.combine( |
| 338 | + datetime.now(timezone.utc).date(), t, tzinfo=timezone.utc |
| 339 | + ) |
| 340 | + except ValueError: |
| 341 | + raise click.BadParameter(f"Cannot parse time: {value!r}") |
| 342 | + |
| 343 | + |
| 344 | +@report.command("timeline") |
| 345 | +@click.argument("experiment_dir", type=click.Path(exists=True)) |
| 346 | +@click.option("--test", "test_id", default=None, help="Filter by test_id") |
| 347 | +@click.option("--service", "service_id", default=None, help="Filter by service_id") |
| 348 | +@click.option( |
| 349 | + "--after", |
| 350 | + "after_str", |
| 351 | + default=None, |
| 352 | + help="Include entries at or after this time (ISO-8601)", |
| 353 | +) |
| 354 | +@click.option( |
| 355 | + "--before", |
| 356 | + "before_str", |
| 357 | + default=None, |
| 358 | + help="Include entries strictly before this time (ISO-8601)", |
| 359 | +) |
| 360 | +@click.option( |
| 361 | + "--limit", |
| 362 | + default=50, |
| 363 | + type=int, |
| 364 | + show_default=True, |
| 365 | + help="Maximum entries to display", |
| 366 | +) |
| 367 | +@click.option( |
| 368 | + "--json", |
| 369 | + "output_json", |
| 370 | + is_flag=True, |
| 371 | + default=False, |
| 372 | + help="Output as JSON (default)", |
| 373 | +) |
| 374 | +@click.option( |
| 375 | + "--human", |
| 376 | + "output_human", |
| 377 | + is_flag=True, |
| 378 | + default=False, |
| 379 | + help="Output as human-readable swimlane", |
| 380 | +) |
| 381 | +@handle_errors |
| 382 | +@pass_context_and_setup_logging |
| 383 | +def timeline( |
| 384 | + _ctx, |
| 385 | + experiment_dir, |
| 386 | + test_id, |
| 387 | + service_id, |
| 388 | + after_str, |
| 389 | + before_str, |
| 390 | + limit, |
| 391 | + output_json, |
| 392 | + output_human, |
| 393 | +): |
| 394 | + r"""Display a chronological timeline of log entries. |
| 395 | +
|
| 396 | + Reads structured.jsonl files from the experiment directory, sorts |
| 397 | + entries by timestamp, and groups them by service for swimlane display. |
| 398 | +
|
| 399 | + \b |
| 400 | + Examples: |
| 401 | + panther report timeline outputs/2024-01-01/exp1 |
| 402 | + panther report timeline outputs/2024-01-01/exp1 --human --service picoquic |
| 403 | + panther report timeline outputs/2024-01-01/exp1 --after 10:00:00 --limit 20 |
| 404 | + """ |
| 405 | + from panther.core.reporting.timeline_renderer import TimelineRenderer |
| 406 | + |
| 407 | + after = _parse_time(after_str) if after_str else None |
| 408 | + before = _parse_time(before_str) if before_str else None |
| 409 | + |
| 410 | + renderer = TimelineRenderer(Path(experiment_dir)) |
| 411 | + |
| 412 | + if output_human: |
| 413 | + text = renderer.render_human( |
| 414 | + test=test_id, |
| 415 | + service=service_id, |
| 416 | + after=after, |
| 417 | + before=before, |
| 418 | + limit=limit, |
| 419 | + ) |
| 420 | + click.echo(text) |
| 421 | + else: |
| 422 | + entries = renderer.render_json( |
| 423 | + test=test_id, |
| 424 | + service=service_id, |
| 425 | + after=after, |
| 426 | + before=before, |
| 427 | + limit=limit, |
| 428 | + ) |
| 429 | + click.echo(json.dumps(entries, indent=2, default=str)) |
| 430 | + |
| 431 | + |
| 432 | +# -- artifacts helpers -- |
| 433 | + |
| 434 | + |
| 435 | +def _format_artifacts_human(artifacts: List[dict]) -> str: |
| 436 | + """Format artifact list as a human-readable table. |
| 437 | +
|
| 438 | + Args: |
| 439 | + artifacts: List of artifact metadata dicts. |
| 440 | +
|
| 441 | + Returns: |
| 442 | + Formatted table string. |
| 443 | + """ |
| 444 | + if not artifacts: |
| 445 | + return "(no matching artifacts)" |
| 446 | + |
| 447 | + # Compute column widths |
| 448 | + max_path = max(len(a.get("path", "")) for a in artifacts) |
| 449 | + max_path = min(max_path, 60) # Cap path width |
| 450 | + max_type = max(len(a.get("type", "")) for a in artifacts) |
| 451 | + |
| 452 | + lines = [] |
| 453 | + header = ( |
| 454 | + f" {'Path':<{max_path}} {'Type':<{max_type}} {'Format':<8} {'Size':>10}" |
| 455 | + ) |
| 456 | + lines.append(header) |
| 457 | + lines.append(f" {'-' * max_path} {'-' * max_type} {'-' * 8} {'-' * 10}") |
| 458 | + |
| 459 | + for a in artifacts: |
| 460 | + path = a.get("path", "") |
| 461 | + if len(path) > max_path: |
| 462 | + path = "..." + path[-(max_path - 3) :] |
| 463 | + size = a.get("size_bytes", 0) |
| 464 | + if size >= 1024 * 1024: |
| 465 | + size_str = f"{size / (1024 * 1024):.1f} MB" |
| 466 | + elif size >= 1024: |
| 467 | + size_str = f"{size / 1024:.1f} KB" |
| 468 | + else: |
| 469 | + size_str = f"{size} B" |
| 470 | + lines.append( |
| 471 | + f" {path:<{max_path}} " |
| 472 | + f"{a.get('type', ''):<{max_type}} " |
| 473 | + f"{a.get('format', ''):<8} " |
| 474 | + f"{size_str:>10}" |
| 475 | + ) |
| 476 | + |
| 477 | + return "\n".join(lines) |
| 478 | + |
| 479 | + |
| 480 | +@report.command("artifacts") |
| 481 | +@click.argument("experiment_dir", type=click.Path(exists=True)) |
| 482 | +@click.option("--test", "test_id", default=None, help="Filter by test_id") |
| 483 | +@click.option("--service", "service_id", default=None, help="Filter by service_id") |
| 484 | +@click.option( |
| 485 | + "--type", |
| 486 | + "artifact_type", |
| 487 | + default=None, |
| 488 | + help="Filter by artifact type (pcap, qlog, log, etc.)", |
| 489 | +) |
| 490 | +@click.option("--phase", default=None, help="Filter by execution phase") |
| 491 | +@click.option( |
| 492 | + "--json", |
| 493 | + "output_json", |
| 494 | + is_flag=True, |
| 495 | + default=False, |
| 496 | + help="Output as JSON (default)", |
| 497 | +) |
| 498 | +@click.option( |
| 499 | + "--human", |
| 500 | + "output_human", |
| 501 | + is_flag=True, |
| 502 | + default=False, |
| 503 | + help="Output as human-readable table", |
| 504 | +) |
| 505 | +@handle_errors |
| 506 | +@pass_context_and_setup_logging |
| 507 | +def artifacts( |
| 508 | + _ctx, |
| 509 | + experiment_dir, |
| 510 | + test_id, |
| 511 | + service_id, |
| 512 | + artifact_type, |
| 513 | + phase, |
| 514 | + output_json, |
| 515 | + output_human, |
| 516 | +): |
| 517 | + r"""Browse artifacts from an experiment output directory. |
| 518 | +
|
| 519 | + Lists output files (logs, pcaps, qlogs, reports, etc.) from the |
| 520 | + experiment directory. Reads output_index.json when available, falls |
| 521 | + back to directory walking otherwise. |
| 522 | +
|
| 523 | + \b |
| 524 | + Examples: |
| 525 | + panther report artifacts outputs/2024-01-01/exp1 |
| 526 | + panther report artifacts outputs/2024-01-01/exp1 --human |
| 527 | + panther report artifacts outputs/2024-01-01/exp1 --type artifact --service picoquic |
| 528 | + """ |
| 529 | + from panther.core.reporting.artifact_browser import ArtifactBrowser |
| 530 | + |
| 531 | + browser = ArtifactBrowser(Path(experiment_dir)) |
| 532 | + results = browser.list_artifacts( |
| 533 | + test_id=test_id, |
| 534 | + service_id=service_id, |
| 535 | + artifact_type=artifact_type, |
| 536 | + phase=phase, |
| 537 | + ) |
| 538 | + |
| 539 | + if output_human: |
| 540 | + click.echo(_format_artifacts_human(results)) |
| 541 | + click.echo() |
| 542 | + _success(f"Found {len(results)} artifact(s)") |
| 543 | + else: |
| 544 | + click.echo(json.dumps(results, indent=2, default=str)) |
| 545 | + |
| 546 | + |
| 547 | +@report.command("diagnose") |
| 548 | +@click.argument("experiment_dir", type=click.Path(exists=True)) |
| 549 | +@click.option( |
| 550 | + "--format", |
| 551 | + "fmt", |
| 552 | + type=click.Choice(["human", "json"]), |
| 553 | + default="human", |
| 554 | + help="Output format (default: human)", |
| 555 | +) |
| 556 | +@handle_errors |
| 557 | +@pass_context_and_setup_logging |
| 558 | +def diagnose(_ctx, experiment_dir, fmt): |
| 559 | + r"""Root cause analysis for a failed experiment. |
| 560 | +
|
| 561 | + Scans structured.jsonl for errors, matches against known failure |
| 562 | + patterns, and reports ranked root causes with log excerpts and |
| 563 | + actionable suggestions. |
| 564 | +
|
| 565 | + \b |
| 566 | + Examples: |
| 567 | + panther report diagnose outputs/2024-01-01/exp1 |
| 568 | + panther report diagnose outputs/2024-01-01/exp1 --format json |
| 569 | + """ |
| 570 | + from panther.core.reporting.root_cause_analyzer import RootCauseAnalyzer |
| 571 | + |
| 572 | + exp_path = Path(experiment_dir) |
| 573 | + analyzer = RootCauseAnalyzer(exp_path) |
| 574 | + causes = analyzer.analyze() |
| 575 | + |
| 576 | + if not causes: |
| 577 | + _info("No errors found -- nothing to diagnose.") |
| 578 | + return |
| 579 | + |
| 580 | + if fmt == "json": |
| 581 | + output = json.dumps([c.to_dict() for c in causes], indent=2, ensure_ascii=False) |
| 582 | + click.echo(output) |
| 583 | + return |
| 584 | + |
| 585 | + # Human-readable output |
| 586 | + click.echo(colored("Root Cause Analysis", "blue", attrs=["bold"])) |
| 587 | + click.echo(colored("=" * 60, "blue")) |
| 588 | + click.echo() |
| 589 | + |
| 590 | + for cause in causes: |
| 591 | + # Rank header |
| 592 | + conf_pct = f"{cause.confidence:.0%}" |
| 593 | + click.echo( |
| 594 | + colored( |
| 595 | + f" #{cause.rank} {cause.pattern_name} (confidence: {conf_pct})", |
| 596 | + "yellow", |
| 597 | + attrs=["bold"], |
| 598 | + ) |
| 599 | + ) |
| 600 | + click.echo(f" Category: {cause.category}") |
| 601 | + |
| 602 | + msg = cause.event.get("message", "") |
| 603 | + if msg: |
| 604 | + click.echo(f" Trigger: {msg}") |
| 605 | + |
| 606 | + svc = cause.event.get("service_id", "") |
| 607 | + if svc: |
| 608 | + click.echo(f" Service: {svc}") |
| 609 | + |
| 610 | + if cause.suggestion: |
| 611 | + click.echo(colored(f" Suggestion: {cause.suggestion}", "green")) |
| 612 | + |
| 613 | + if cause.log_excerpt: |
| 614 | + click.echo(colored(" Log excerpt:", "cyan")) |
| 615 | + for line in cause.log_excerpt: |
| 616 | + click.echo(f" {line}") |
| 617 | + |
| 618 | + click.echo() |
| 619 | + |
| 620 | + _success(f"Found {len(causes)} root cause(s)") |
| 621 | + |
| 622 | + |
309 | 623 | if __name__ == "__main__": |
310 | 624 | report() |
0 commit comments