Skip to content

Commit a67b0c0

Browse files
committed
feat(cli): add timeline renderer, artifact browser, and report subcommands
Add TimelineRenderer for chronological swimlane display of structured log entries, and ArtifactBrowser for listing/filtering experiment output files from output_index.json (with directory walk fallback). Register both as `panther report timeline` and `panther report artifacts` CLI subcommands with filtering by test, service, time range, phase, and artifact type.
1 parent 5cfaa55 commit a67b0c0

6 files changed

Lines changed: 1381 additions & 0 deletions

File tree

panther/cli/commands/report.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
33
Manage experiment reports with generation, viewing, and listing capabilities.
44
Wraps the reporting subsystem (ExperimentReporter, StatusCollector) for CLI use.
5+
Includes timeline and artifact browsing subcommands.
56
"""
67

8+
import json
79
from datetime import datetime
810
from pathlib import Path
911
from typing import List, Tuple
@@ -306,5 +308,317 @@ def show(_ctx, experiment_dir):
306308
_success("Report displayed")
307309

308310

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+
309623
if __name__ == "__main__":
310624
report()

panther/core/reporting/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,31 @@
1414
timing breakdowns, and configuration metadata.
1515
"""
1616

17+
from .artifact_browser import ArtifactBrowser
1718
from .experiment_reporter import ExperimentReporter
19+
from .failure_patterns import BUILTIN_PATTERNS, FailurePattern
1820
from .log_query_engine import LogFilter, LogQueryEngine
1921
from .result_serialization import (
2022
save_experiment_result,
2123
save_implementation_logs,
2224
save_test_result,
2325
)
26+
from .root_cause_analyzer import RootCause, RootCauseAnalyzer
2427
from .sequence_trace import SequenceOff, SequenceOn
2528
from .status_collector import StatusCollector
29+
from .timeline_renderer import TimelineRenderer
2630

2731
__all__ = [
32+
"ArtifactBrowser",
33+
"BUILTIN_PATTERNS",
2834
"ExperimentReporter",
35+
"FailurePattern",
2936
"LogFilter",
3037
"LogQueryEngine",
38+
"RootCause",
39+
"RootCauseAnalyzer",
3140
"StatusCollector",
41+
"TimelineRenderer",
3242
"save_experiment_result",
3343
"save_test_result",
3444
"save_implementation_logs",

0 commit comments

Comments
 (0)