logler 1.0.7__cp311-cp311-win_amd64.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.
logler/cli.py ADDED
@@ -0,0 +1,589 @@
1
+ """
2
+ Command-line interface for Logler.
3
+ """
4
+
5
+ import click
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import asyncio
10
+ import socket
11
+ from contextlib import closing
12
+
13
+ from .terminal import TerminalViewer
14
+ from .web.app import run_server
15
+ from .llm_cli import llm as llm_group
16
+
17
+
18
+ @click.group(invoke_without_command=True)
19
+ @click.pass_context
20
+ @click.version_option()
21
+ def main(ctx):
22
+ """
23
+ šŸ” Logler - Beautiful local log viewer
24
+
25
+ A modern log viewer with thread tracking, real-time updates, and beautiful output.
26
+ """
27
+ if ctx.invoked_subcommand is None:
28
+ click.echo(ctx.get_help())
29
+
30
+
31
+ def _find_open_port(host: str, start_port: int, max_tries: int = 20) -> int:
32
+ """Find the next available port starting from start_port."""
33
+ for candidate in range(start_port, start_port + max_tries):
34
+ with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
35
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
36
+ try:
37
+ sock.bind((host, candidate))
38
+ return candidate
39
+ except OSError:
40
+ continue
41
+ raise RuntimeError(f"No open port found in range {start_port}-{start_port + max_tries - 1}")
42
+
43
+
44
+ @main.command()
45
+ @click.option("--host", default="0.0.0.0", help="Host to bind to")
46
+ @click.option("--port", default=7607, help="Port to bind to (default 7607 ~ 'LOGL')")
47
+ @click.option(
48
+ "--auto-port/--no-auto-port",
49
+ default=True,
50
+ help="Pick the next free port if the chosen one is busy",
51
+ )
52
+ @click.option("--open", "-o", is_flag=True, help="Open browser automatically")
53
+ @click.argument("files", nargs=-1, type=click.Path(exists=True))
54
+ def serve(host: str, port: int, auto_port: bool, open: bool, files: tuple):
55
+ """
56
+ Start the web server interface.
57
+
58
+ Examples:
59
+ logler serve # Start with file picker
60
+ logler serve app.log # Start with specific file
61
+ logler serve *.log # Start with multiple files
62
+ """
63
+ if auto_port:
64
+ chosen_port = _find_open_port(host, port)
65
+ if chosen_port != port:
66
+ click.echo(f"āš ļø Port {port} busy, using {chosen_port} instead")
67
+ port = chosen_port
68
+
69
+ click.echo(f"šŸš€ Starting Logler web server on http://{host}:{port}")
70
+
71
+ file_paths = [str(Path(f).absolute()) for f in files] if files else []
72
+
73
+ if open:
74
+ import webbrowser
75
+
76
+ webbrowser.open(f"http://localhost:{port}")
77
+
78
+ asyncio.run(run_server(host, port, file_paths))
79
+
80
+
81
+ @main.command()
82
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
83
+ @click.option("-n", "--lines", type=int, help="Number of lines to show")
84
+ @click.option("-f", "--follow", is_flag=True, help="Follow log file in real-time")
85
+ @click.option("--level", type=str, help="Filter by log level (DEBUG, INFO, WARN, ERROR)")
86
+ @click.option("--grep", type=str, help="Search for pattern")
87
+ @click.option("--thread", type=str, help="Filter by thread ID")
88
+ @click.option("--no-color", is_flag=True, help="Disable colored output")
89
+ def view(
90
+ files: tuple,
91
+ lines: Optional[int],
92
+ follow: bool,
93
+ level: Optional[str],
94
+ grep: Optional[str],
95
+ thread: Optional[str],
96
+ no_color: bool,
97
+ ):
98
+ """
99
+ View log files in the terminal with beautiful output.
100
+
101
+ Examples:
102
+ logler view app.log # View entire file
103
+ logler view app.log -n 100 # Last 100 lines
104
+ logler view app.log -f # Follow in real-time
105
+ logler view app.log --level ERROR # Show only errors
106
+ logler view app.log --grep "timeout" # Search for pattern
107
+ logler view app.log --thread worker-1 # Filter by thread
108
+ """
109
+ viewer = TerminalViewer(use_colors=not no_color)
110
+
111
+ for file_path in files:
112
+ try:
113
+ asyncio.run(
114
+ viewer.view_file(
115
+ file_path=file_path,
116
+ lines=lines,
117
+ follow=follow,
118
+ level_filter=level,
119
+ pattern=grep,
120
+ thread_filter=thread,
121
+ )
122
+ )
123
+ except KeyboardInterrupt:
124
+ click.echo("\nšŸ‘‹ Goodbye!")
125
+ sys.exit(0)
126
+ except Exception as e:
127
+ click.echo(f"āŒ Error: {e}", err=True)
128
+ sys.exit(1)
129
+
130
+
131
+ @main.command()
132
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
133
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
134
+ def stats(files: tuple, output_json: bool):
135
+ """
136
+ Show statistics for log files.
137
+
138
+ Examples:
139
+ logler stats app.log # Show statistics
140
+ logler stats app.log --json # Output as JSON
141
+ """
142
+ from .parser import LogParser
143
+ from rich.console import Console
144
+ from rich.table import Table
145
+
146
+ console = Console()
147
+ parser = LogParser()
148
+
149
+ for file_path in files:
150
+ with open(file_path, "r") as f:
151
+ entries = [parser.parse_line(i + 1, line.rstrip()) for i, line in enumerate(f)]
152
+
153
+ stats_data = {
154
+ "total": len(entries),
155
+ "by_level": {},
156
+ "by_thread": {},
157
+ "errors": 0,
158
+ }
159
+
160
+ for entry in entries:
161
+ level = str(entry.level)
162
+ stats_data["by_level"][level] = stats_data["by_level"].get(level, 0) + 1
163
+
164
+ if entry.level in ["ERROR", "FATAL", "CRITICAL"]:
165
+ stats_data["errors"] += 1
166
+
167
+ if entry.thread_id:
168
+ stats_data["by_thread"][entry.thread_id] = (
169
+ stats_data["by_thread"].get(entry.thread_id, 0) + 1
170
+ )
171
+
172
+ if output_json:
173
+ console.print_json(data=stats_data)
174
+ else:
175
+ console.print(f"\n[bold]šŸ“Š Statistics for {file_path}[/bold]\n")
176
+
177
+ table = Table(title="Log Levels")
178
+ table.add_column("Level", style="cyan")
179
+ table.add_column("Count", justify="right", style="green")
180
+
181
+ for level, count in sorted(stats_data["by_level"].items()):
182
+ table.add_row(level, str(count))
183
+
184
+ console.print(table)
185
+
186
+ console.print(f"\n[bold red]Errors:[/bold red] {stats_data['errors']}")
187
+ console.print(f"[bold]Total:[/bold] {stats_data['total']} entries\n")
188
+
189
+
190
+ @main.command()
191
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
192
+ @click.option("--auto-insights", is_flag=True, help="Run automatic insights analysis")
193
+ @click.option("--errors", is_flag=True, help="Show only errors with analysis")
194
+ @click.option("--patterns", is_flag=True, help="Find repeated patterns")
195
+ @click.option("--thread", type=str, help="Follow specific thread ID")
196
+ @click.option("--correlation", type=str, help="Follow specific correlation ID")
197
+ @click.option(
198
+ "--hierarchy", is_flag=True, help="Show thread hierarchy tree (with --thread or --correlation)"
199
+ )
200
+ @click.option("--waterfall", is_flag=True, help="Show waterfall timeline (with --hierarchy)")
201
+ @click.option("--flamegraph", is_flag=True, help="Show flamegraph visualization (with --hierarchy)")
202
+ @click.option(
203
+ "--show-error-flow",
204
+ is_flag=True,
205
+ help="Analyze error propagation through hierarchy (with --hierarchy)",
206
+ )
207
+ @click.option("--max-depth", type=int, help="Maximum hierarchy depth to display")
208
+ @click.option(
209
+ "--min-confidence",
210
+ type=float,
211
+ default=0.0,
212
+ help="Minimum confidence for hierarchy detection (0.0-1.0)",
213
+ )
214
+ @click.option("--context", type=int, default=3, help="Number of context lines (default: 3)")
215
+ @click.option(
216
+ "--output",
217
+ type=click.Choice(["full", "summary", "count", "compact"]),
218
+ default="summary",
219
+ help="Output format (default: summary)",
220
+ )
221
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
222
+ @click.option(
223
+ "--min-occurrences", type=int, default=3, help="Minimum pattern occurrences (default: 3)"
224
+ )
225
+ def investigate(
226
+ files: tuple,
227
+ auto_insights: bool,
228
+ errors: bool,
229
+ patterns: bool,
230
+ thread: Optional[str],
231
+ correlation: Optional[str],
232
+ hierarchy: bool,
233
+ waterfall: bool,
234
+ flamegraph: bool,
235
+ show_error_flow: bool,
236
+ max_depth: Optional[int],
237
+ min_confidence: float,
238
+ context: int,
239
+ output: str,
240
+ output_json: bool,
241
+ min_occurrences: int,
242
+ ):
243
+ """
244
+ Investigate log files with smart analysis and insights.
245
+
246
+ Examples:
247
+ logler investigate app.log --auto-insights # Auto-detect issues
248
+ logler investigate app.log --errors # Analyze errors
249
+ logler investigate app.log --patterns # Find repeated patterns
250
+ logler investigate app.log --thread worker-1 # Follow specific thread
251
+ logler investigate app.log --correlation req-123 # Follow request
252
+ logler investigate app.log --thread req-123 --hierarchy # Show hierarchy tree
253
+ logler investigate app.log --thread req-123 --hierarchy --waterfall # Show waterfall timeline
254
+ logler investigate app.log --thread req-123 --hierarchy --flamegraph # Show flamegraph
255
+ logler investigate app.log --hierarchy --show-error-flow # Analyze error propagation
256
+ logler investigate app.log --output summary # Token-efficient output
257
+ """
258
+ from .investigate import (
259
+ analyze_with_insights,
260
+ search,
261
+ find_patterns,
262
+ follow_thread,
263
+ follow_thread_hierarchy,
264
+ get_hierarchy_summary,
265
+ analyze_error_flow,
266
+ format_error_flow,
267
+ )
268
+ from rich.console import Console
269
+ from rich.table import Table
270
+ from rich.panel import Panel
271
+
272
+ console = Console()
273
+ file_list = list(files)
274
+
275
+ try:
276
+ # Auto-insights mode (most powerful)
277
+ if auto_insights:
278
+ console.print("[bold cyan]šŸŽÆ Running automatic insights analysis...[/bold cyan]\n")
279
+ result = analyze_with_insights(files=file_list, auto_investigate=True)
280
+
281
+ if output_json:
282
+ console.print_json(data=result)
283
+ return
284
+
285
+ # Display overview
286
+ overview = result["overview"]
287
+ console.print(
288
+ Panel(
289
+ f"[bold]Total Logs:[/bold] {overview['total_logs']}\n"
290
+ f"[bold]Error Count:[/bold] {overview['error_count']}\n"
291
+ f"[bold]Error Rate:[/bold] {overview['error_rate']:.1%}\n"
292
+ f"[bold]Log Levels:[/bold] {overview['log_levels']}",
293
+ title="šŸ“Š Overview",
294
+ border_style="cyan",
295
+ )
296
+ )
297
+
298
+ # Display insights
299
+ if result["insights"]:
300
+ console.print("\n[bold cyan]šŸ’” Automatic Insights[/bold cyan]\n")
301
+ for i, insight in enumerate(result["insights"], 1):
302
+ severity_color = {"high": "red", "medium": "yellow", "low": "green"}.get(
303
+ insight["severity"], "white"
304
+ )
305
+
306
+ severity_icon = {"high": "šŸ”“", "medium": "🟔", "low": "🟢"}.get(
307
+ insight["severity"], "⚪"
308
+ )
309
+
310
+ console.print(
311
+ f"{severity_icon} [bold {severity_color}]Insight #{i}:[/bold {severity_color}] {insight['type']}"
312
+ )
313
+ console.print(
314
+ f" [dim]Severity:[/dim] [{severity_color}]{insight['severity'].upper()}[/{severity_color}]"
315
+ )
316
+ console.print(f" [dim]Description:[/dim] {insight['description']}")
317
+ console.print(f" [dim]Suggestion:[/dim] {insight['suggestion']}\n")
318
+
319
+ # Display suggestions
320
+ if result["suggestions"]:
321
+ console.print("[bold cyan]šŸ“ Suggestions[/bold cyan]\n")
322
+ for i, suggestion in enumerate(result["suggestions"], 1):
323
+ console.print(f" {i}. {suggestion}")
324
+
325
+ # Display next steps
326
+ if result["next_steps"]:
327
+ console.print("\n[bold cyan]šŸš€ Next Steps[/bold cyan]\n")
328
+ for i, step in enumerate(result["next_steps"], 1):
329
+ console.print(f" {i}. {step}")
330
+
331
+ # Pattern detection mode
332
+ elif patterns:
333
+ console.print(
334
+ f"[bold cyan]šŸ” Finding repeated patterns (min {min_occurrences} occurrences)...[/bold cyan]\n"
335
+ )
336
+ result = find_patterns(files=file_list, min_occurrences=min_occurrences)
337
+
338
+ if output_json:
339
+ console.print_json(data=result)
340
+ return
341
+
342
+ pattern_list = result.get("patterns", [])
343
+ if pattern_list:
344
+ table = Table(title=f"Found {len(pattern_list)} Patterns")
345
+ table.add_column("Pattern", style="cyan", no_wrap=False)
346
+ table.add_column("Count", justify="right", style="green")
347
+ table.add_column("First Seen", style="yellow")
348
+ table.add_column("Last Seen", style="yellow")
349
+
350
+ for pattern in pattern_list[:20]: # Show top 20
351
+ pattern_text = pattern.get("pattern", "")[:80]
352
+ count = pattern.get("occurrences", 0)
353
+ first = pattern.get("first_seen", "N/A")
354
+ last = pattern.get("last_seen", "N/A")
355
+ table.add_row(pattern_text, str(count), first, last)
356
+
357
+ console.print(table)
358
+ else:
359
+ console.print("[yellow]No repeated patterns found.[/yellow]")
360
+
361
+ # Thread/correlation following mode
362
+ elif thread or correlation:
363
+ identifier = thread or correlation
364
+ id_type = "thread" if thread else "correlation"
365
+
366
+ # Hierarchy mode
367
+ if hierarchy:
368
+ console.print(
369
+ f"[bold cyan]🌳 Building hierarchy for {id_type}: {identifier}...[/bold cyan]\n"
370
+ )
371
+
372
+ try:
373
+ hier_result = follow_thread_hierarchy(
374
+ files=file_list,
375
+ root_identifier=identifier,
376
+ max_depth=max_depth,
377
+ min_confidence=min_confidence,
378
+ )
379
+
380
+ if output_json:
381
+ console.print_json(data=hier_result)
382
+ return
383
+
384
+ # Import tree formatter
385
+ from .tree_formatter import format_tree, format_waterfall, format_flamegraph
386
+
387
+ # Show summary first
388
+ summary = get_hierarchy_summary(hier_result)
389
+ console.print(summary)
390
+ console.print()
391
+
392
+ # Show tree visualization
393
+ if waterfall:
394
+ console.print("[bold cyan]šŸ“Š Waterfall Timeline[/bold cyan]\n")
395
+ waterfall_str = format_waterfall(hier_result, width=100)
396
+ console.print(waterfall_str)
397
+ elif flamegraph:
398
+ console.print("[bold cyan]šŸ”„ Flamegraph Visualization[/bold cyan]\n")
399
+ flamegraph_str = format_flamegraph(hier_result, width=100)
400
+ console.print(flamegraph_str)
401
+ else:
402
+ console.print("[bold cyan]🌲 Hierarchy Tree[/bold cyan]\n")
403
+ tree_str = format_tree(
404
+ hier_result,
405
+ mode="detailed",
406
+ show_duration=True,
407
+ show_errors=True,
408
+ max_depth=max_depth,
409
+ use_colors=True,
410
+ )
411
+ console.print(tree_str)
412
+
413
+ # Show error flow analysis if requested
414
+ if show_error_flow:
415
+ console.print()
416
+ console.print("[bold cyan]šŸ” Error Flow Analysis[/bold cyan]\n")
417
+ error_analysis = analyze_error_flow(hier_result)
418
+ if output_json:
419
+ console.print_json(data=error_analysis)
420
+ else:
421
+ error_flow_str = format_error_flow(error_analysis)
422
+ console.print(error_flow_str)
423
+
424
+ except Exception as e:
425
+ console.print(f"[red]āŒ Error building hierarchy: {e}[/red]")
426
+ console.print("[yellow]Falling back to regular thread following...[/yellow]\n")
427
+ hierarchy = False # Fall through to regular mode
428
+
429
+ # Regular thread following mode
430
+ if not hierarchy:
431
+ console.print(f"[bold cyan]🧵 Following {id_type}: {identifier}...[/bold cyan]\n")
432
+
433
+ result = follow_thread(
434
+ files=file_list, thread_id=thread, correlation_id=correlation
435
+ )
436
+
437
+ if output_json:
438
+ console.print_json(data=result)
439
+ return
440
+
441
+ entries = result.get("entries", [])
442
+ total = result.get("total_entries", len(entries))
443
+ duration = result.get("duration_ms", 0)
444
+
445
+ console.print(f"[bold]Found {total} entries[/bold]")
446
+ if duration:
447
+ console.print(f"[bold]Duration:[/bold] {duration}ms\n")
448
+
449
+ # Display entries
450
+ for entry in entries[:50]: # Limit display to 50
451
+ timestamp = entry.get("timestamp", "N/A")
452
+ level = entry.get("level", "INFO")
453
+ message = entry.get("message", "")[:100]
454
+
455
+ level_color = {
456
+ "ERROR": "red",
457
+ "FATAL": "red",
458
+ "WARN": "yellow",
459
+ "WARNING": "yellow",
460
+ "INFO": "cyan",
461
+ "DEBUG": "dim",
462
+ "TRACE": "dim",
463
+ }.get(level, "white")
464
+
465
+ console.print(
466
+ f"[dim]{timestamp}[/dim] [{level_color}]{level:8s}[/{level_color}] {message}"
467
+ )
468
+
469
+ if len(entries) > 50:
470
+ console.print(f"\n[dim]... and {len(entries) - 50} more entries[/dim]")
471
+
472
+ # Error analysis mode
473
+ elif errors:
474
+ console.print("[bold cyan]āŒ Analyzing errors...[/bold cyan]\n")
475
+ result = search(
476
+ files=file_list, level="ERROR", context_lines=context, output_format=output
477
+ )
478
+
479
+ if output_json:
480
+ console.print_json(data=result)
481
+ return
482
+
483
+ if output == "summary":
484
+ total = result.get("total_matches", 0)
485
+ unique = result.get("unique_messages", 0)
486
+ console.print(f"[bold]Total Errors:[/bold] {total}")
487
+ console.print(f"[bold]Unique Messages:[/bold] {unique}\n")
488
+
489
+ top_messages = result.get("top_messages", [])
490
+ if top_messages:
491
+ table = Table(title="Top Error Messages")
492
+ table.add_column("Message", style="red", no_wrap=False)
493
+ table.add_column("Count", justify="right", style="green")
494
+ table.add_column("First Seen", style="yellow")
495
+
496
+ for msg in top_messages[:10]:
497
+ message = msg.get("message", "")[:80]
498
+ count = msg.get("count", 0)
499
+ first = msg.get("first_seen", "N/A")
500
+ table.add_row(message, str(count), first)
501
+
502
+ console.print(table)
503
+
504
+ elif output == "count":
505
+ console.print_json(data=result)
506
+
507
+ elif output == "compact":
508
+ matches = result.get("matches", [])
509
+ for match in matches[:50]:
510
+ time = match.get("time", "N/A")
511
+ msg = match.get("msg", "")
512
+ console.print(f"[dim]{time}[/dim] [red]ERROR[/red] {msg}")
513
+
514
+ else: # full
515
+ results = result.get("results", [])
516
+ for item in results[:50]:
517
+ entry = item.get("entry", {})
518
+ timestamp = entry.get("timestamp", "N/A")
519
+ message = entry.get("message", "")
520
+ console.print(f"[dim]{timestamp}[/dim] [red]ERROR[/red] {message}")
521
+
522
+ # Default search mode
523
+ else:
524
+ console.print("[bold cyan]šŸ” Searching logs...[/bold cyan]\n")
525
+ result = search(files=file_list, context_lines=context, output_format=output)
526
+
527
+ if output_json:
528
+ console.print_json(data=result)
529
+ return
530
+
531
+ total = result.get("total_matches", 0)
532
+ console.print(f"[bold]Total matches:[/bold] {total}\n")
533
+
534
+ if output == "summary":
535
+ console.print_json(data=result)
536
+ elif output == "count":
537
+ console.print_json(data=result)
538
+ else:
539
+ results = result.get("results", [])
540
+ for item in results[:50]:
541
+ entry = item.get("entry", {})
542
+ timestamp = entry.get("timestamp", "N/A")
543
+ level = entry.get("level", "INFO")
544
+ message = entry.get("message", "")[:100]
545
+ console.print(f"[dim]{timestamp}[/dim] {level:8s} {message}")
546
+
547
+ except Exception as e:
548
+ console.print(f"[red]āŒ Error:[/red] {e}", err=True)
549
+ if "--debug" in sys.argv:
550
+ import traceback
551
+
552
+ traceback.print_exc()
553
+ sys.exit(1)
554
+
555
+
556
+ @main.command()
557
+ @click.argument("pattern", required=True)
558
+ @click.option("--directory", "-d", default=".", help="Directory to watch")
559
+ @click.option("--recursive", "-r", is_flag=True, help="Watch recursively")
560
+ def watch(pattern: str, directory: str, recursive: bool):
561
+ """
562
+ Watch for new log files matching a pattern.
563
+
564
+ Examples:
565
+ logler watch "*.log" # Watch current directory
566
+ logler watch "app-*.log" -d /var/log # Watch specific directory
567
+ logler watch "*.log" -r # Watch recursively
568
+ """
569
+ from .watcher import FileWatcher
570
+ from rich.console import Console
571
+
572
+ console = Console()
573
+ console.print(f"šŸ‘€ Watching for files matching: [cyan]{pattern}[/cyan]")
574
+ console.print(f"šŸ“‚ Directory: [yellow]{directory}[/yellow]")
575
+
576
+ watcher = FileWatcher(pattern, directory, recursive)
577
+
578
+ try:
579
+ asyncio.run(watcher.watch())
580
+ except KeyboardInterrupt:
581
+ console.print("\nšŸ‘‹ Stopped watching")
582
+
583
+
584
+ # Register the LLM command group
585
+ main.add_command(llm_group)
586
+
587
+
588
+ if __name__ == "__main__":
589
+ main()