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 +1 -0
- log4lab/cli.py +64 -0
- log4lab/server.py +155 -0
- log4lab/tail.py +283 -0
- log4lab/templates/index.html +579 -0
- log4lab/templates/runs.html +182 -0
- log4lab-0.1.0.dist-info/METADATA +338 -0
- log4lab-0.1.0.dist-info/RECORD +14 -0
- log4lab-0.1.0.dist-info/WHEEL +5 -0
- log4lab-0.1.0.dist-info/entry_points.txt +2 -0
- log4lab-0.1.0.dist-info/licenses/LICENSE +21 -0
- log4lab-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_server.py +214 -0
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
|