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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ log4lab = log4lab.cli:app
@@ -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.
@@ -0,0 +1,2 @@
1
+ log4lab
2
+ tests
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()