log4lab 0.0.2__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 +123 -0
- log4lab/export.py +744 -0
- log4lab/server.py +155 -0
- log4lab/tail.py +283 -0
- log4lab/templates/index.html +717 -0
- log4lab/templates/runs.html +182 -0
- log4lab-0.0.2.dist-info/METADATA +222 -0
- log4lab-0.0.2.dist-info/RECORD +15 -0
- log4lab-0.0.2.dist-info/WHEEL +5 -0
- log4lab-0.0.2.dist-info/entry_points.txt +2 -0
- log4lab-0.0.2.dist-info/licenses/LICENSE +21 -0
- log4lab-0.0.2.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_server.py +214 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark:bg-gray-900 dark:text-gray-100">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Log4Lab — Run IDs</title>
|
|
6
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
7
|
+
<script>
|
|
8
|
+
if (localStorage.theme === 'dark' ||
|
|
9
|
+
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
10
|
+
document.documentElement.classList.add('dark');
|
|
11
|
+
} else {
|
|
12
|
+
document.documentElement.classList.remove('dark');
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
</head>
|
|
16
|
+
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
|
|
17
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
18
|
+
<div class="flex justify-between items-center mb-6">
|
|
19
|
+
<h1 class="text-3xl font-bold">Log4Lab <span class="text-sm font-light">(Runs Index)</span></h1>
|
|
20
|
+
<div class="flex space-x-2">
|
|
21
|
+
<a href="/" class="px-3 py-1 rounded border text-sm bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
22
|
+
📊 View Logs
|
|
23
|
+
</a>
|
|
24
|
+
<button id="themeToggle"
|
|
25
|
+
class="px-3 py-1 rounded border text-sm bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700">
|
|
26
|
+
Toggle Theme
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
|
32
|
+
<p class="text-sm text-blue-800 dark:text-blue-200">
|
|
33
|
+
Click on a run name to view all runs, or click on a specific run ID to view that run's logs.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div id="loading" class="text-center py-8">
|
|
38
|
+
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
|
39
|
+
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading runs...</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div id="run-container" class="hidden">
|
|
43
|
+
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
44
|
+
Found <span id="run-count" class="font-semibold">0</span> unique run names
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div id="run-list" class="space-y-4"></div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div id="no-runs" class="hidden text-center py-8 text-gray-600 dark:text-gray-400">
|
|
51
|
+
<p>No runs found in the log file.</p>
|
|
52
|
+
<p class="text-sm mt-2">Runs are identified by the "run_name" and "run_id" fields in log entries.</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<script>
|
|
57
|
+
const themeToggle = document.getElementById("themeToggle");
|
|
58
|
+
const loading = document.getElementById("loading");
|
|
59
|
+
const runContainer = document.getElementById("run-container");
|
|
60
|
+
const noRuns = document.getElementById("no-runs");
|
|
61
|
+
const runList = document.getElementById("run-list");
|
|
62
|
+
const runCount = document.getElementById("run-count");
|
|
63
|
+
|
|
64
|
+
themeToggle.onclick = () => {
|
|
65
|
+
const html = document.documentElement;
|
|
66
|
+
if (html.classList.contains('dark')) {
|
|
67
|
+
html.classList.remove('dark');
|
|
68
|
+
localStorage.theme = 'light';
|
|
69
|
+
} else {
|
|
70
|
+
html.classList.add('dark');
|
|
71
|
+
localStorage.theme = 'dark';
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Helper function to format date and calculate duration
|
|
76
|
+
function formatRunInfo(earliest, latest) {
|
|
77
|
+
if (!earliest) {
|
|
78
|
+
return { date: 'N/A', duration: 'N/A' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle timestamp with or without 'Z'
|
|
82
|
+
let ts = earliest;
|
|
83
|
+
if (!ts.endsWith('Z') && !ts.includes('+') && !ts.includes('-', 10)) {
|
|
84
|
+
ts = ts + 'Z';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const startDate = new Date(ts);
|
|
88
|
+
const dateStr = startDate.toLocaleDateString('en-US', {
|
|
89
|
+
month: 'short',
|
|
90
|
+
day: 'numeric',
|
|
91
|
+
year: 'numeric',
|
|
92
|
+
hour: '2-digit',
|
|
93
|
+
minute: '2-digit'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!latest || earliest === latest) {
|
|
97
|
+
return { date: dateStr, duration: '0h' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Calculate duration in hours
|
|
101
|
+
let tsLatest = latest;
|
|
102
|
+
if (!tsLatest.endsWith('Z') && !tsLatest.includes('+') && !tsLatest.includes('-', 10)) {
|
|
103
|
+
tsLatest = tsLatest + 'Z';
|
|
104
|
+
}
|
|
105
|
+
const endDate = new Date(tsLatest);
|
|
106
|
+
const durationMs = endDate - startDate;
|
|
107
|
+
const durationHours = Math.round(durationMs / (1000 * 60 * 60) * 10) / 10; // Round to 1 decimal
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
date: dateStr,
|
|
111
|
+
duration: durationHours >= 1 ? `${durationHours}h` : `${Math.round(durationMs / (1000 * 60))}m`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fetch runs from server
|
|
116
|
+
fetch('/api/runs')
|
|
117
|
+
.then(response => response.json())
|
|
118
|
+
.then(data => {
|
|
119
|
+
loading.classList.add('hidden');
|
|
120
|
+
|
|
121
|
+
if (data.runs && Object.keys(data.runs).length > 0) {
|
|
122
|
+
runContainer.classList.remove('hidden');
|
|
123
|
+
runCount.textContent = Object.keys(data.runs).length;
|
|
124
|
+
|
|
125
|
+
Object.keys(data.runs).sort().forEach(runName => {
|
|
126
|
+
const runData = data.runs[runName];
|
|
127
|
+
const totalLogs = runData.total;
|
|
128
|
+
const runIds = runData.run_ids;
|
|
129
|
+
|
|
130
|
+
const div = document.createElement('div');
|
|
131
|
+
div.className = 'p-4 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-700 rounded';
|
|
132
|
+
|
|
133
|
+
div.innerHTML = `
|
|
134
|
+
<div class="flex justify-between items-center mb-3">
|
|
135
|
+
<div class="flex-1">
|
|
136
|
+
<a href="/?run_name=${encodeURIComponent(runName)}"
|
|
137
|
+
class="text-xl font-semibold text-blue-600 dark:text-blue-400 hover:underline">
|
|
138
|
+
${runName}
|
|
139
|
+
</a>
|
|
140
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
141
|
+
${totalLogs} log ${totalLogs === 1 ? 'entry' : 'entries'} across ${runIds.length} run${runIds.length === 1 ? '' : 's'}
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
<div>
|
|
145
|
+
<a href="/?run_name=${encodeURIComponent(runName)}"
|
|
146
|
+
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded text-sm">
|
|
147
|
+
View All →
|
|
148
|
+
</a>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
|
152
|
+
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">INDIVIDUAL RUNS:</p>
|
|
153
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
154
|
+
${runIds.map(runIdInfo => {
|
|
155
|
+
const info = formatRunInfo(runIdInfo.earliest, runIdInfo.latest);
|
|
156
|
+
return `
|
|
157
|
+
<a href="/?run_id=${encodeURIComponent(runIdInfo.run_id)}"
|
|
158
|
+
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-sm border border-gray-300 dark:border-gray-600 transition">
|
|
159
|
+
<div class="text-xs font-semibold text-gray-600 dark:text-gray-300">${info.date}</div>
|
|
160
|
+
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
|
161
|
+
Duration: ${info.duration} • ${runIdInfo.count} ${runIdInfo.count === 1 ? 'entry' : 'entries'}
|
|
162
|
+
</div>
|
|
163
|
+
</a>
|
|
164
|
+
`}).join('')}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
runList.appendChild(div);
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
noRuns.classList.remove('hidden');
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
.catch(error => {
|
|
176
|
+
loading.classList.add('hidden');
|
|
177
|
+
noRuns.classList.remove('hidden');
|
|
178
|
+
console.error('Error fetching runs:', error);
|
|
179
|
+
});
|
|
180
|
+
</script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: log4lab
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A lightweight structured log viewer with live streaming and filters.
|
|
5
|
+
Author: Thibaut Lamadon
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Thibaut Lamadon
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/tlamadon/log4lab
|
|
29
|
+
Project-URL: Repository, https://github.com/tlamadon/log4lab
|
|
30
|
+
Project-URL: Issues, https://github.com/tlamadon/log4lab/issues
|
|
31
|
+
Keywords: logging,log-viewer,structured-logs,jsonl,monitoring,dashboard,live-streaming
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
41
|
+
Classifier: Topic :: System :: Logging
|
|
42
|
+
Classifier: Topic :: System :: Monitoring
|
|
43
|
+
Requires-Python: >=3.8
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
License-File: LICENSE
|
|
46
|
+
Requires-Dist: fastapi
|
|
47
|
+
Requires-Dist: uvicorn
|
|
48
|
+
Requires-Dist: jinja2
|
|
49
|
+
Requires-Dist: typer
|
|
50
|
+
Requires-Dist: rich>=13.0.0
|
|
51
|
+
Provides-Extra: test
|
|
52
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
53
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
54
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
55
|
+
Requires-Dist: httpx>=0.24; extra == "test"
|
|
56
|
+
Provides-Extra: images
|
|
57
|
+
Requires-Dist: term-image>=0.7.0; extra == "images"
|
|
58
|
+
Dynamic: license-file
|
|
59
|
+
|
|
60
|
+
# Log4Lab
|
|
61
|
+
|
|
62
|
+
A lightweight structured log viewer with live streaming, filtering, and rich content rendering.
|
|
63
|
+
|
|
64
|
+
Log4Lab is a web-based dashboard for viewing and analyzing structured JSON logs in real-time. It provides a clean interface for monitoring application logs with live updates, making it easy to track experiments and debug issues.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
### Using pipx (Recommended)
|
|
69
|
+
|
|
70
|
+
Install log4lab as an isolated application:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pipx install log4lab
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Using pip
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install log4lab
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### From source
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone https://github.com/tlamadon/log4lab.git
|
|
86
|
+
cd log4lab
|
|
87
|
+
pip install -e .
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Quick Start
|
|
91
|
+
|
|
92
|
+
1. **Start the web interface:**
|
|
93
|
+
```bash
|
|
94
|
+
log4lab serve logs/app.log
|
|
95
|
+
```
|
|
96
|
+
Then open http://localhost:8000
|
|
97
|
+
|
|
98
|
+
2. **View logs in terminal:**
|
|
99
|
+
```bash
|
|
100
|
+
log4lab tail logs/app.log
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
3. **Export to HTML:**
|
|
104
|
+
```bash
|
|
105
|
+
log4lab export logs/app.log -o report.html
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Log Format
|
|
109
|
+
|
|
110
|
+
Log4Lab expects JSONL format (one JSON object per line):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{"time": "2025-02-03T10:30:00Z", "level": "INFO", "section": "train", "message": "Training started"}
|
|
114
|
+
{"time": "2025-02-03T10:30:05Z", "level": "WARN", "section": "data", "message": "Missing data point"}
|
|
115
|
+
{"time": "2025-02-03T10:30:10Z", "level": "ERROR", "section": "model", "message": "Model failed to converge"}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### With Rich Content
|
|
119
|
+
|
|
120
|
+
Log4Lab can display various file types when you include a `cache_path` field:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"time": "2025-02-03T10:30:00Z",
|
|
125
|
+
"level": "INFO",
|
|
126
|
+
"section": "train",
|
|
127
|
+
"message": "Training complete",
|
|
128
|
+
"cache_path": "artifacts/results.png",
|
|
129
|
+
"run_name": "experiment_1",
|
|
130
|
+
"accuracy": 0.95
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Supported content types:**
|
|
135
|
+
- **Images**: PNG, JPG, SVG, etc. (displayed inline)
|
|
136
|
+
- **Code files**: Python, JavaScript, JSON, YAML, etc. (syntax highlighted, collapsible)
|
|
137
|
+
- **Markdown**: .md files (rendered as HTML, collapsible)
|
|
138
|
+
- **PDFs**: Embedded viewer
|
|
139
|
+
- **Other files**: Download links
|
|
140
|
+
|
|
141
|
+
### Core Fields
|
|
142
|
+
|
|
143
|
+
- `time`: Timestamp in ISO 8601 format
|
|
144
|
+
- `level`: Log level (INFO, WARN, ERROR, DEBUG)
|
|
145
|
+
- `section`: Component or module name
|
|
146
|
+
- `message` or `msg`: Main log message
|
|
147
|
+
- `cache_path`: Path to artifact file
|
|
148
|
+
- `run_name`: Name of the run collection
|
|
149
|
+
- `run_id`: Unique run identifier
|
|
150
|
+
|
|
151
|
+
Any additional fields are shown in the expandable JSON view.
|
|
152
|
+
|
|
153
|
+
## Commands
|
|
154
|
+
|
|
155
|
+
### Serve (Web Interface)
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
log4lab serve [LOGFILE]
|
|
159
|
+
|
|
160
|
+
# Options:
|
|
161
|
+
--host 0.0.0.0 # Bind to all interfaces (default: 127.0.0.1)
|
|
162
|
+
--port 3000 # Custom port (default: 8000)
|
|
163
|
+
--reload # Auto-reload for development
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Tail (Terminal)
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
log4lab tail [LOGFILE]
|
|
170
|
+
|
|
171
|
+
# Options:
|
|
172
|
+
--level ERROR # Filter by log level
|
|
173
|
+
--open-images # Open images automatically
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Export
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
log4lab export [LOGFILE] -o output.html
|
|
180
|
+
|
|
181
|
+
# Options:
|
|
182
|
+
-o, --output FILE # Output file path
|
|
183
|
+
-t, --title TEXT # Custom page title
|
|
184
|
+
--no-embed-images # Don't embed images (smaller file)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Filtering
|
|
188
|
+
|
|
189
|
+
The web interface supports:
|
|
190
|
+
- **Level filtering**: Dropdown with all levels found in logs
|
|
191
|
+
- **Text filters**: Section, run name, run ID (partial matching)
|
|
192
|
+
- **Time range**: Last 1m, 5m, 30m, 1h, 6h, 24h
|
|
193
|
+
- **URL filters**: Bookmark and share filtered views
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
# Filter by error logs from last hour
|
|
197
|
+
http://localhost:8000/?level=error&time=3600
|
|
198
|
+
|
|
199
|
+
# View specific run
|
|
200
|
+
http://localhost:8000/?run_name=experiment_1
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Use Cases
|
|
204
|
+
|
|
205
|
+
**Machine Learning**: Track training runs with metrics and plots
|
|
206
|
+
```json
|
|
207
|
+
{"run_name": "resnet_training", "epoch": 10, "loss": 0.23, "cache_path": "plots/loss.png"}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Debugging**: Monitor distributed systems across components
|
|
211
|
+
```json
|
|
212
|
+
{"section": "api", "level": "ERROR", "message": "Connection timeout", "duration_ms": 5000}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Research**: Document experiments with artifacts
|
|
216
|
+
```json
|
|
217
|
+
{"run_name": "param_sweep", "message": "Testing lr=0.001", "cache_path": "results.pdf"}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
log4lab/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
log4lab/cli.py,sha256=GodvLIcU1SQnqvwzJzQhiqzPLyQWV62DjBhc9hzgavk,4105
|
|
3
|
+
log4lab/export.py,sha256=VbThX0xFmJuqM3NXpTPJSTOiEiA_HwLT5XHorw1bRsk,27118
|
|
4
|
+
log4lab/server.py,sha256=a_YBuVGMT7kRpqreIYci92Uc_gsNGBf4vctUJ2kFcLk,5728
|
|
5
|
+
log4lab/tail.py,sha256=GjGFt0Tv-q8IXzn-fx6FidyNVqp5SjJljLFGD-X8BIo,10633
|
|
6
|
+
log4lab/templates/index.html,sha256=Dx_FvPUHpZwjefevq9-vG9o6_D70j-uK4FUO3OYjgc4,28644
|
|
7
|
+
log4lab/templates/runs.html,sha256=G2RJDJ8AmnDKFjw-6psvXk0vpLOberuRfcmAZBv6Kx0,7117
|
|
8
|
+
log4lab-0.0.2.dist-info/licenses/LICENSE,sha256=_85wPgyXGMMXDX1ms9iKkJZWt4_vB4HS9t7GHiIfZ1Q,1072
|
|
9
|
+
tests/__init__.py,sha256=yW0onu9xodryo1GfHYJjOSfuLXy6K_v9pLxSJchJdJw,21
|
|
10
|
+
tests/test_server.py,sha256=FUIEc-lpi8P9sSA8TNAHQ6WWnLBtrqLGpg58R6uQSfA,6736
|
|
11
|
+
log4lab-0.0.2.dist-info/METADATA,sha256=ea5N1Fef4j2mACaOwQd99Ls3QfTTZ4mQIYqvhW5sAXg,6596
|
|
12
|
+
log4lab-0.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
log4lab-0.0.2.dist-info/entry_points.txt,sha256=65wVwwTX1qbSz_45R5vCxwY5JgEZeQGJ5liy301mF6I,44
|
|
14
|
+
log4lab-0.0.2.dist-info/top_level.txt,sha256=yKLnl06W1Q-LESO4FPA-9dzSceCDusANhZV3jmyRYTc,14
|
|
15
|
+
log4lab-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Thibaut Lamadon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests for LogBoard
|
tests/test_server.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for Log4Lab server endpoints."""
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from log4lab import server
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def temp_log_file():
|
|
14
|
+
"""Create a temporary log file with test data."""
|
|
15
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
|
|
16
|
+
# Write test log entries
|
|
17
|
+
logs = [
|
|
18
|
+
{
|
|
19
|
+
"time": "2025-10-24T10:00:00Z",
|
|
20
|
+
"level": "INFO",
|
|
21
|
+
"section": "test",
|
|
22
|
+
"message": "First log entry",
|
|
23
|
+
"run_name": "test_run",
|
|
24
|
+
"run_id": "run_001",
|
|
25
|
+
"group": "test_group"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"time": "2025-10-24T10:05:00Z",
|
|
29
|
+
"level": "ERROR",
|
|
30
|
+
"section": "test",
|
|
31
|
+
"message": "Error log entry",
|
|
32
|
+
"run_name": "test_run",
|
|
33
|
+
"run_id": "run_001"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"time": "2025-10-24T11:00:00Z",
|
|
37
|
+
"level": "INFO",
|
|
38
|
+
"section": "another",
|
|
39
|
+
"message": "Different run",
|
|
40
|
+
"run_name": "test_run",
|
|
41
|
+
"run_id": "run_002"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"time": "2025-10-24T12:00:00Z",
|
|
45
|
+
"level": "DEBUG",
|
|
46
|
+
"section": "debug",
|
|
47
|
+
"message": "Debug message",
|
|
48
|
+
"run_name": "another_run",
|
|
49
|
+
"run_id": "run_003"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
for log in logs:
|
|
53
|
+
f.write(json.dumps(log) + '\n')
|
|
54
|
+
temp_path = Path(f.name)
|
|
55
|
+
|
|
56
|
+
yield temp_path
|
|
57
|
+
|
|
58
|
+
# Cleanup
|
|
59
|
+
temp_path.unlink()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def client(temp_log_file):
|
|
64
|
+
"""Create a test client with a temporary log file."""
|
|
65
|
+
server.set_log_path(temp_log_file)
|
|
66
|
+
return TestClient(server.app)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_index_page(client):
|
|
70
|
+
"""Test that the index page loads successfully."""
|
|
71
|
+
response = client.get("/")
|
|
72
|
+
assert response.status_code == 200
|
|
73
|
+
assert "Log4Lab" in response.text
|
|
74
|
+
assert "Live" in response.text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_runs_page(client):
|
|
78
|
+
"""Test that the runs index page loads successfully."""
|
|
79
|
+
response = client.get("/runs")
|
|
80
|
+
assert response.status_code == 200
|
|
81
|
+
assert "Log4Lab" in response.text
|
|
82
|
+
assert "Runs Index" in response.text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_api_runs_endpoint(client, temp_log_file):
|
|
86
|
+
"""Test the /api/runs endpoint returns correct data structure."""
|
|
87
|
+
response = client.get("/api/runs")
|
|
88
|
+
assert response.status_code == 200
|
|
89
|
+
|
|
90
|
+
data = response.json()
|
|
91
|
+
assert "runs" in data
|
|
92
|
+
|
|
93
|
+
# Check that we have the expected run names
|
|
94
|
+
assert "test_run" in data["runs"]
|
|
95
|
+
assert "another_run" in data["runs"]
|
|
96
|
+
|
|
97
|
+
# Check test_run structure
|
|
98
|
+
test_run = data["runs"]["test_run"]
|
|
99
|
+
assert "total" in test_run
|
|
100
|
+
assert "run_ids" in test_run
|
|
101
|
+
assert test_run["total"] == 3 # Three entries for test_run
|
|
102
|
+
|
|
103
|
+
# Check run_ids structure
|
|
104
|
+
assert len(test_run["run_ids"]) == 2 # run_001 and run_002
|
|
105
|
+
|
|
106
|
+
run_id_001 = next(r for r in test_run["run_ids"] if r["run_id"] == "run_001")
|
|
107
|
+
assert run_id_001["count"] == 2
|
|
108
|
+
assert run_id_001["earliest"] == "2025-10-24T10:00:00Z"
|
|
109
|
+
assert run_id_001["latest"] == "2025-10-24T10:05:00Z"
|
|
110
|
+
|
|
111
|
+
run_id_002 = next(r for r in test_run["run_ids"] if r["run_id"] == "run_002")
|
|
112
|
+
assert run_id_002["count"] == 1
|
|
113
|
+
assert run_id_002["earliest"] == "2025-10-24T11:00:00Z"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_api_runs_empty_file():
|
|
117
|
+
"""Test /api/runs with an empty log file."""
|
|
118
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
|
|
119
|
+
temp_path = Path(f.name)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
server.set_log_path(temp_path)
|
|
123
|
+
client = TestClient(server.app)
|
|
124
|
+
|
|
125
|
+
response = client.get("/api/runs")
|
|
126
|
+
assert response.status_code == 200
|
|
127
|
+
|
|
128
|
+
data = response.json()
|
|
129
|
+
assert data["runs"] == {}
|
|
130
|
+
finally:
|
|
131
|
+
temp_path.unlink()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_api_runs_no_run_fields():
|
|
135
|
+
"""Test /api/runs with logs that don't have run_name/run_id fields."""
|
|
136
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
|
|
137
|
+
logs = [
|
|
138
|
+
{"time": "2025-10-24T10:00:00Z", "level": "INFO", "message": "No run info"},
|
|
139
|
+
{"time": "2025-10-24T10:05:00Z", "level": "ERROR", "message": "Another entry"}
|
|
140
|
+
]
|
|
141
|
+
for log in logs:
|
|
142
|
+
f.write(json.dumps(log) + '\n')
|
|
143
|
+
temp_path = Path(f.name)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
server.set_log_path(temp_path)
|
|
147
|
+
client = TestClient(server.app)
|
|
148
|
+
|
|
149
|
+
response = client.get("/api/runs")
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
|
|
152
|
+
data = response.json()
|
|
153
|
+
assert data["runs"] == {}
|
|
154
|
+
finally:
|
|
155
|
+
temp_path.unlink()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_cache_file_serving(client, temp_log_file):
|
|
159
|
+
"""Test serving cache files from the log directory."""
|
|
160
|
+
# Create a test file in the same directory as the log file
|
|
161
|
+
log_dir = temp_log_file.parent
|
|
162
|
+
test_file = log_dir / "test_artifact.txt"
|
|
163
|
+
test_file.write_text("Test artifact content")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = client.get("/cache/test_artifact.txt")
|
|
167
|
+
assert response.status_code == 200
|
|
168
|
+
assert response.text == "Test artifact content"
|
|
169
|
+
finally:
|
|
170
|
+
test_file.unlink()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_cache_file_not_found(client):
|
|
174
|
+
"""Test that non-existent cache files return 404."""
|
|
175
|
+
response = client.get("/cache/nonexistent.txt")
|
|
176
|
+
assert response.status_code == 404
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_cache_file_path_traversal_protection(client):
|
|
180
|
+
"""Test that path traversal attacks are blocked."""
|
|
181
|
+
response = client.get("/cache/../../../etc/passwd")
|
|
182
|
+
# Should return an error code (403, 400, or 404 if path doesn't resolve)
|
|
183
|
+
assert response.status_code in [403, 400, 404]
|
|
184
|
+
# Most importantly, should NOT return 200
|
|
185
|
+
assert response.status_code != 200
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_get_set_log_path(temp_log_file):
|
|
189
|
+
"""Test the get_log_path and set_log_path functions."""
|
|
190
|
+
server.set_log_path(temp_log_file)
|
|
191
|
+
assert server.get_log_path() == temp_log_file
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_invalid_json_lines(client):
|
|
195
|
+
"""Test that invalid JSON lines are skipped gracefully."""
|
|
196
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
|
|
197
|
+
f.write('{"valid": "json"}\n')
|
|
198
|
+
f.write('invalid json line\n')
|
|
199
|
+
f.write('{"another": "valid", "run_name": "test", "run_id": "1"}\n')
|
|
200
|
+
temp_path = Path(f.name)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
server.set_log_path(temp_path)
|
|
204
|
+
client = TestClient(server.app)
|
|
205
|
+
|
|
206
|
+
response = client.get("/api/runs")
|
|
207
|
+
assert response.status_code == 200
|
|
208
|
+
|
|
209
|
+
data = response.json()
|
|
210
|
+
# Should only count the valid entries with run info
|
|
211
|
+
assert len(data["runs"]) == 1
|
|
212
|
+
assert "test" in data["runs"]
|
|
213
|
+
finally:
|
|
214
|
+
temp_path.unlink()
|