log4lab 0.1.0__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.
log4lab/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
log4lab/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ import typer
2
+ import uvicorn
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from . import server
6
+ from .tail import LogTailer
7
+
8
+ app = typer.Typer(help="Log4Lab — a lightweight structured log dashboard")
9
+
10
+
11
+ @app.command()
12
+ def serve(
13
+ logfile: Path = typer.Argument(
14
+ "logs/app.log",
15
+ exists=False,
16
+ help="Path to the JSONL log file to stream (default: logs/app.log)"
17
+ ),
18
+ host: str = typer.Option("127.0.0.1", help="Host to bind to"),
19
+ port: int = typer.Option(8000, help="Port to listen on"),
20
+ reload: bool = typer.Option(False, help="Enable auto-reload"),
21
+ ):
22
+ """Start the Log4Lab web server."""
23
+ server.set_log_path(logfile)
24
+ uvicorn.run("log4lab.server:app", host=host, port=port, reload=reload)
25
+
26
+
27
+ @app.command()
28
+ def tail(
29
+ logfile: Path = typer.Argument(
30
+ "logs/app.log",
31
+ help="Path to the JSONL log file to tail"
32
+ ),
33
+ level: Optional[str] = typer.Option(None, "--level", "-l", help="Filter by log level (e.g., INFO, ERROR)"),
34
+ section: Optional[str] = typer.Option(None, "--section", "-s", help="Filter by section name"),
35
+ run_name: Optional[str] = typer.Option(None, "--run-name", "-r", help="Filter by run name"),
36
+ run_id: Optional[str] = typer.Option(None, "--run-id", help="Filter by run ID"),
37
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Filter by group name"),
38
+ time_range: Optional[int] = typer.Option(None, "--time-range", "-t", help="Only show logs from last N seconds"),
39
+ follow: bool = typer.Option(True, "--follow/--no-follow", "-f", help="Follow log file for new entries"),
40
+ show_images: bool = typer.Option(True, "--images/--no-images", help="Try to show images inline in terminal"),
41
+ open_images: bool = typer.Option(False, "--open-images", help="Open images in system default viewer (Preview, etc.)"),
42
+ ):
43
+ """Tail logs to the terminal with rich formatting and filters."""
44
+ tailer = LogTailer(
45
+ log_path=logfile,
46
+ level=level,
47
+ section=section,
48
+ run_name=run_name,
49
+ run_id=run_id,
50
+ group=group,
51
+ time_range=time_range,
52
+ follow=follow,
53
+ show_images=show_images,
54
+ open_images=open_images,
55
+ )
56
+ try:
57
+ tailer.tail()
58
+ except KeyboardInterrupt:
59
+ pass # Clean exit on Ctrl+C
60
+
61
+
62
+ if __name__ == "__main__":
63
+ app()
64
+
log4lab/server.py ADDED
@@ -0,0 +1,155 @@
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
3
+ from jinja2 import Environment, FileSystemLoader
4
+ import asyncio, json
5
+ from pathlib import Path
6
+ import mimetypes
7
+
8
+ # Global variable to store the log path
9
+ _LOG_PATH = Path("logs/app.log")
10
+
11
+ env = Environment(loader=FileSystemLoader(str(Path(__file__).parent / "templates")))
12
+ template = env.get_template("index.html")
13
+
14
+ def set_log_path(log_path: Path):
15
+ """Set the log path for the server."""
16
+ global _LOG_PATH
17
+ _LOG_PATH = log_path
18
+
19
+ def get_log_path() -> Path:
20
+ """Get the current log path."""
21
+ return _LOG_PATH
22
+
23
+ app = FastAPI()
24
+
25
+ @app.get("/", response_class=HTMLResponse)
26
+ async def show_page():
27
+ return template.render()
28
+
29
+ @app.get("/runs", response_class=HTMLResponse)
30
+ async def show_runs_page():
31
+ """Show the run IDs index page."""
32
+ runs_template = env.get_template("runs.html")
33
+ return runs_template.render()
34
+
35
+ @app.get("/api/runs")
36
+ async def get_runs():
37
+ """Get run_names with their run_ids from the log file."""
38
+ log_path = get_log_path()
39
+ runs = {} # {run_name: {run_ids: {run_id: {count, earliest, latest}}, total: X}}
40
+
41
+ if log_path.exists():
42
+ with log_path.open() as f:
43
+ for line in f:
44
+ line = line.strip()
45
+ if not line:
46
+ continue
47
+ try:
48
+ obj = json.loads(line)
49
+ run_name = obj.get('run_name')
50
+ run_id = obj.get('run_id')
51
+ timestamp = obj.get('time')
52
+
53
+ if run_name:
54
+ run_name = str(run_name)
55
+ if run_name not in runs:
56
+ runs[run_name] = {'run_ids': {}, 'total': 0}
57
+
58
+ runs[run_name]['total'] += 1
59
+
60
+ if run_id:
61
+ run_id = str(run_id)
62
+ if run_id not in runs[run_name]['run_ids']:
63
+ runs[run_name]['run_ids'][run_id] = {
64
+ 'count': 0,
65
+ 'earliest': None,
66
+ 'latest': None
67
+ }
68
+ runs[run_name]['run_ids'][run_id]['count'] += 1
69
+
70
+ # Track earliest and latest timestamps
71
+ if timestamp:
72
+ if runs[run_name]['run_ids'][run_id]['earliest'] is None:
73
+ runs[run_name]['run_ids'][run_id]['earliest'] = timestamp
74
+ runs[run_name]['run_ids'][run_id]['latest'] = timestamp
75
+ else:
76
+ if timestamp < runs[run_name]['run_ids'][run_id]['earliest']:
77
+ runs[run_name]['run_ids'][run_id]['earliest'] = timestamp
78
+ if timestamp > runs[run_name]['run_ids'][run_id]['latest']:
79
+ runs[run_name]['run_ids'][run_id]['latest'] = timestamp
80
+
81
+ except json.JSONDecodeError:
82
+ continue
83
+
84
+ # Convert to frontend-friendly format
85
+ result = {}
86
+ for run_name, data in runs.items():
87
+ result[run_name] = {
88
+ 'total': data['total'],
89
+ 'run_ids': [
90
+ {
91
+ 'run_id': rid,
92
+ 'count': info['count'],
93
+ 'earliest': info['earliest'],
94
+ 'latest': info['latest']
95
+ }
96
+ for rid, info in sorted(data['run_ids'].items())
97
+ ]
98
+ }
99
+
100
+ return {"runs": result}
101
+
102
+ async def stream_logs():
103
+ last_size = 0
104
+ while True:
105
+ log_path = get_log_path()
106
+ if log_path.exists():
107
+ size = log_path.stat().st_size
108
+ if size < last_size:
109
+ last_size = 0
110
+ if size > last_size:
111
+ with log_path.open() as f:
112
+ f.seek(last_size)
113
+ for line in f:
114
+ line = line.strip()
115
+ if not line:
116
+ continue
117
+ try:
118
+ obj = json.loads(line)
119
+ yield f"data: {json.dumps(obj)}\n\n"
120
+ except json.JSONDecodeError:
121
+ continue
122
+ last_size = f.tell()
123
+ await asyncio.sleep(1)
124
+
125
+ @app.get("/stream")
126
+ async def sse_endpoint():
127
+ return StreamingResponse(stream_logs(), media_type="text/event-stream")
128
+
129
+ @app.get("/cache/{file_path:path}")
130
+ async def serve_cache_file(file_path: str):
131
+ """Serve cache files relative to the log file directory."""
132
+ log_dir = get_log_path().parent
133
+ requested_file = log_dir / file_path
134
+
135
+ # Security: ensure the file is within the log directory or subdirectories
136
+ try:
137
+ requested_file = requested_file.resolve()
138
+ log_dir = log_dir.resolve()
139
+ if not str(requested_file).startswith(str(log_dir)):
140
+ raise HTTPException(status_code=403, detail="Access denied")
141
+ except (ValueError, OSError):
142
+ raise HTTPException(status_code=400, detail="Invalid file path")
143
+
144
+ if not requested_file.exists():
145
+ raise HTTPException(status_code=404, detail="File not found")
146
+
147
+ if not requested_file.is_file():
148
+ raise HTTPException(status_code=400, detail="Not a file")
149
+
150
+ # Determine the media type
151
+ mime_type, _ = mimetypes.guess_type(str(requested_file))
152
+ if mime_type is None:
153
+ mime_type = "application/octet-stream"
154
+
155
+ return FileResponse(requested_file, media_type=mime_type)
log4lab/tail.py ADDED
@@ -0,0 +1,283 @@
1
+ """Terminal-based log tailing with live streaming and image support."""
2
+ import json
3
+ import time
4
+ import subprocess
5
+ import platform
6
+ from pathlib import Path
7
+ from datetime import datetime, timezone
8
+ from typing import Optional, List
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+ from rich.live import Live
15
+ from rich.layout import Layout
16
+ from rich import box
17
+
18
+ try:
19
+ from term_image.image import from_file
20
+ IMAGES_SUPPORTED = True
21
+ except (ImportError, Exception):
22
+ # term-image may not be available or may have dependency issues
23
+ IMAGES_SUPPORTED = False
24
+
25
+
26
+ class LogTailer:
27
+ """Tail JSONL log files with rich terminal output."""
28
+
29
+ def __init__(
30
+ self,
31
+ log_path: Path,
32
+ level: Optional[str] = None,
33
+ section: Optional[str] = None,
34
+ run_name: Optional[str] = None,
35
+ run_id: Optional[str] = None,
36
+ group: Optional[str] = None,
37
+ time_range: Optional[int] = None,
38
+ follow: bool = True,
39
+ show_images: bool = True,
40
+ open_images: bool = False,
41
+ ):
42
+ self.log_path = log_path
43
+ self.level = level.upper() if level else None
44
+ self.section = section
45
+ self.run_name = run_name
46
+ self.run_id = run_id
47
+ self.group = group
48
+ self.time_range = time_range # seconds
49
+ self.follow = follow
50
+ self.show_images = show_images and IMAGES_SUPPORTED
51
+ self.open_images = open_images
52
+ self.console = Console()
53
+
54
+ def matches_filters(self, entry: dict) -> bool:
55
+ """Check if log entry matches all filters."""
56
+ # Level filter
57
+ if self.level and entry.get("level", "").upper() != self.level:
58
+ return False
59
+
60
+ # Section filter
61
+ if self.section and self.section.lower() not in entry.get("section", "").lower():
62
+ return False
63
+
64
+ # Run name filter
65
+ if self.run_name and self.run_name.lower() not in entry.get("run_name", "").lower():
66
+ return False
67
+
68
+ # Run ID filter
69
+ if self.run_id and self.run_id.lower() not in entry.get("run_id", "").lower():
70
+ return False
71
+
72
+ # Group filter
73
+ if self.group and self.group.lower() not in entry.get("group", "").lower():
74
+ return False
75
+
76
+ # Time range filter
77
+ if self.time_range and "time" in entry:
78
+ try:
79
+ log_time = datetime.fromisoformat(entry["time"].replace("Z", "+00:00"))
80
+ now = datetime.now(timezone.utc)
81
+ if (now - log_time).total_seconds() > self.time_range:
82
+ return False
83
+ except (ValueError, AttributeError):
84
+ pass
85
+
86
+ return True
87
+
88
+ def format_level(self, level: str) -> Text:
89
+ """Format log level with colors."""
90
+ level = level.upper()
91
+ colors = {
92
+ "ERROR": "red bold",
93
+ "WARN": "yellow bold",
94
+ "WARNING": "yellow bold",
95
+ "INFO": "blue bold",
96
+ "DEBUG": "cyan",
97
+ "TRACE": "dim",
98
+ }
99
+ color = colors.get(level, "white")
100
+ return Text(level.ljust(7), style=color)
101
+
102
+ def format_timestamp(self, timestamp: str) -> Text:
103
+ """Format timestamp."""
104
+ try:
105
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
106
+ return Text(dt.strftime("%H:%M:%S"), style="dim")
107
+ except (ValueError, AttributeError):
108
+ return Text(timestamp[:8] if len(timestamp) > 8 else timestamp, style="dim")
109
+
110
+ def format_entry(self, entry: dict) -> Panel:
111
+ """Format a log entry as a rich panel."""
112
+ # Header with timestamp, level, and metadata
113
+ header_parts = []
114
+
115
+ if "time" in entry:
116
+ header_parts.append(self.format_timestamp(entry["time"]))
117
+
118
+ if "level" in entry:
119
+ header_parts.append(self.format_level(entry["level"]))
120
+
121
+ if "section" in entry:
122
+ header_parts.append(Text(f"[{entry['section']}]", style="magenta"))
123
+
124
+ if "run_name" in entry:
125
+ header_parts.append(Text(f"run:{entry['run_name']}", style="green"))
126
+
127
+ if "run_id" in entry:
128
+ header_parts.append(Text(f"id:{entry['run_id']}", style="green dim"))
129
+
130
+ if "group" in entry:
131
+ header_parts.append(Text(f"group:{entry['group']}", style="yellow"))
132
+
133
+ header = Text(" ").join(header_parts)
134
+
135
+ # Message (main content)
136
+ message = entry.get("message") or entry.get("msg", "")
137
+ message_text = Text(message, style="white")
138
+
139
+ # Additional fields (exclude known fields)
140
+ known_fields = {"time", "level", "section", "message", "msg", "run_name", "run_id", "group", "cache_path"}
141
+ extra_fields = {k: v for k, v in entry.items() if k not in known_fields}
142
+
143
+ content_parts = [message_text]
144
+
145
+ if extra_fields:
146
+ content_parts.append(Text("\n"))
147
+ extra_text = Text(json.dumps(extra_fields, indent=2), style="dim")
148
+ content_parts.append(extra_text)
149
+
150
+ content = Text("").join(content_parts)
151
+
152
+ # Create panel
153
+ border_style = {
154
+ "ERROR": "red",
155
+ "WARN": "yellow",
156
+ "WARNING": "yellow",
157
+ "INFO": "blue",
158
+ "DEBUG": "cyan",
159
+ }.get(entry.get("level", "").upper(), "white")
160
+
161
+ panel = Panel(
162
+ content,
163
+ title=header,
164
+ title_align="left",
165
+ border_style=border_style,
166
+ box=box.ROUNDED,
167
+ )
168
+
169
+ return panel
170
+
171
+ def open_image_externally(self, image_path: Path):
172
+ """Open image in system default viewer."""
173
+ try:
174
+ system = platform.system()
175
+ if system == 'Darwin': # macOS
176
+ subprocess.run(['open', str(image_path)], check=True)
177
+ elif system == 'Linux':
178
+ subprocess.run(['xdg-open', str(image_path)], check=True)
179
+ elif system == 'Windows':
180
+ subprocess.run(['start', str(image_path)], shell=True, check=True)
181
+ self.console.print(f"[dim green]✓ Opened {image_path.name} in system viewer[/dim green]")
182
+ except Exception as e:
183
+ self.console.print(f"[dim yellow]Could not open image: {e}[/dim yellow]")
184
+
185
+ def show_image(self, image_path: Path):
186
+ """Display image inline in terminal if supported."""
187
+ if not image_path.exists():
188
+ self.console.print(f"[dim red]📷 Image not found: {image_path}[/dim red]")
189
+ return
190
+
191
+ # Check if file is an image
192
+ if image_path.suffix.lower() not in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}:
193
+ self.console.print(f"[dim]📄 File: {image_path}[/dim]")
194
+ return
195
+
196
+ # Option 1: Open in external viewer (most reliable)
197
+ if self.open_images:
198
+ self.open_image_externally(image_path)
199
+ return
200
+
201
+ # Option 2: Show path with helpful message (default behavior)
202
+ # Inline rendering is disabled by default due to unreliability
203
+ self.console.print(f"[dim cyan]📷 Image: {image_path}[/dim cyan]")
204
+ if self.show_images:
205
+ self.console.print(f"[dim] 💡 Tip: Use --open-images to view in Preview/image viewer[/dim]")
206
+
207
+ def tail(self):
208
+ """Tail the log file and display entries."""
209
+ if not self.log_path.exists():
210
+ self.console.print(f"[red]Error: Log file not found: {self.log_path}[/red]")
211
+ return
212
+
213
+ # Print header
214
+ self.console.print(Panel(
215
+ f"[bold]Tailing log file:[/bold] {self.log_path}\n"
216
+ f"[dim]Press Ctrl+C to stop[/dim]",
217
+ border_style="blue",
218
+ ))
219
+
220
+ # Show active filters
221
+ if any([self.level, self.section, self.run_name, self.run_id, self.group, self.time_range]):
222
+ filters = []
223
+ if self.level:
224
+ filters.append(f"level={self.level}")
225
+ if self.section:
226
+ filters.append(f"section={self.section}")
227
+ if self.run_name:
228
+ filters.append(f"run_name={self.run_name}")
229
+ if self.run_id:
230
+ filters.append(f"run_id={self.run_id}")
231
+ if self.group:
232
+ filters.append(f"group={self.group}")
233
+ if self.time_range:
234
+ filters.append(f"time_range={self.time_range}s")
235
+ self.console.print(f"[yellow]Active filters: {', '.join(filters)}[/yellow]\n")
236
+
237
+ with open(self.log_path, 'r') as f:
238
+ # Read existing lines first
239
+ for line in f:
240
+ line = line.strip()
241
+ if not line:
242
+ continue
243
+
244
+ try:
245
+ entry = json.loads(line)
246
+ if self.matches_filters(entry):
247
+ self.console.print(self.format_entry(entry))
248
+
249
+ # Show image if cache_path exists
250
+ if "cache_path" in entry:
251
+ cache_path = Path(entry["cache_path"])
252
+ if not cache_path.is_absolute():
253
+ cache_path = self.log_path.parent / cache_path
254
+ self.show_image(cache_path)
255
+
256
+ self.console.print() # Empty line between entries
257
+ except json.JSONDecodeError:
258
+ continue
259
+
260
+ # Follow mode: watch for new lines
261
+ if self.follow:
262
+ while True:
263
+ line = f.readline()
264
+ if line:
265
+ line = line.strip()
266
+ if line:
267
+ try:
268
+ entry = json.loads(line)
269
+ if self.matches_filters(entry):
270
+ self.console.print(self.format_entry(entry))
271
+
272
+ # Show image if cache_path exists
273
+ if "cache_path" in entry:
274
+ cache_path = Path(entry["cache_path"])
275
+ if not cache_path.is_absolute():
276
+ cache_path = self.log_path.parent / cache_path
277
+ self.show_image(cache_path)
278
+
279
+ self.console.print() # Empty line between entries
280
+ except json.JSONDecodeError:
281
+ continue
282
+ else:
283
+ time.sleep(0.1) # Short sleep to avoid busy waiting