logtap 0.2.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.
- logtap/__init__.py +8 -0
- logtap/__main__.py +6 -0
- logtap/api/__init__.py +5 -0
- logtap/api/app.py +45 -0
- logtap/api/dependencies.py +61 -0
- logtap/api/routes/__init__.py +1 -0
- logtap/api/routes/files.py +38 -0
- logtap/api/routes/health.py +19 -0
- logtap/api/routes/logs.py +249 -0
- logtap/api/routes/parsed.py +102 -0
- logtap/cli/__init__.py +1 -0
- logtap/cli/commands/__init__.py +1 -0
- logtap/cli/commands/files.py +86 -0
- logtap/cli/commands/query.py +127 -0
- logtap/cli/commands/serve.py +78 -0
- logtap/cli/commands/tail.py +121 -0
- logtap/cli/main.py +50 -0
- logtap/core/__init__.py +16 -0
- logtap/core/parsers/__init__.py +20 -0
- logtap/core/parsers/apache.py +165 -0
- logtap/core/parsers/auto.py +118 -0
- logtap/core/parsers/base.py +164 -0
- logtap/core/parsers/json_parser.py +119 -0
- logtap/core/parsers/nginx.py +108 -0
- logtap/core/parsers/syslog.py +80 -0
- logtap/core/reader.py +160 -0
- logtap/core/search.py +142 -0
- logtap/core/validation.py +52 -0
- logtap/models/__init__.py +11 -0
- logtap/models/config.py +39 -0
- logtap/models/responses.py +65 -0
- logtap-0.2.0.dist-info/METADATA +317 -0
- logtap-0.2.0.dist-info/RECORD +36 -0
- logtap-0.2.0.dist-info/WHEEL +4 -0
- logtap-0.2.0.dist-info/entry_points.txt +3 -0
- logtap-0.2.0.dist-info/licenses/LICENSE +674 -0
logtap/__init__.py
ADDED
logtap/__main__.py
ADDED
logtap/api/__init__.py
ADDED
logtap/api/app.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""FastAPI application factory for logtap."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
|
|
6
|
+
from logtap import __version__
|
|
7
|
+
from logtap.api.routes import files, health, logs, parsed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_app() -> FastAPI:
|
|
11
|
+
"""
|
|
12
|
+
Create and configure the FastAPI application.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Configured FastAPI application instance.
|
|
16
|
+
"""
|
|
17
|
+
app = FastAPI(
|
|
18
|
+
title="logtap",
|
|
19
|
+
description="A CLI-first log access tool for Unix systems.",
|
|
20
|
+
version=__version__,
|
|
21
|
+
docs_url="/docs",
|
|
22
|
+
redoc_url="/redoc",
|
|
23
|
+
openapi_url="/openapi.json",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Configure CORS
|
|
27
|
+
app.add_middleware(
|
|
28
|
+
CORSMiddleware,
|
|
29
|
+
allow_origins=["*"],
|
|
30
|
+
allow_credentials=True,
|
|
31
|
+
allow_methods=["*"],
|
|
32
|
+
allow_headers=["*"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Include routers
|
|
36
|
+
app.include_router(health.router, tags=["health"])
|
|
37
|
+
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
|
38
|
+
app.include_router(files.router, prefix="/files", tags=["files"])
|
|
39
|
+
app.include_router(parsed.router, prefix="/parsed", tags=["parsed"])
|
|
40
|
+
|
|
41
|
+
return app
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Create default app instance for uvicorn
|
|
45
|
+
app = create_app()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""FastAPI dependencies for logtap."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import Header, HTTPException, status
|
|
8
|
+
|
|
9
|
+
from logtap.models.config import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@lru_cache()
|
|
13
|
+
def get_settings() -> Settings:
|
|
14
|
+
"""
|
|
15
|
+
Get cached application settings.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Application settings instance.
|
|
19
|
+
"""
|
|
20
|
+
return Settings()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def verify_api_key(
|
|
24
|
+
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
|
25
|
+
) -> Optional[str]:
|
|
26
|
+
"""
|
|
27
|
+
Verify the API key if authentication is enabled.
|
|
28
|
+
|
|
29
|
+
If LOGTAP_API_KEY is not set, authentication is disabled and all requests are allowed.
|
|
30
|
+
If LOGTAP_API_KEY is set, requests must include a matching X-API-Key header.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
x_api_key: The API key from the request header.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The validated API key, or None if authentication is disabled.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
HTTPException: If authentication is enabled and the key is invalid.
|
|
40
|
+
"""
|
|
41
|
+
settings = get_settings()
|
|
42
|
+
|
|
43
|
+
# If no API key is configured, skip authentication
|
|
44
|
+
if not settings.api_key:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# API key is required but not provided
|
|
48
|
+
if not x_api_key:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
51
|
+
detail="API key required. Set X-API-Key header.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Use timing-safe comparison to prevent timing attacks
|
|
55
|
+
if not secrets.compare_digest(x_api_key, settings.api_key):
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
58
|
+
detail="Invalid API key.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return x_api_key
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routes for logtap."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""File listing endpoint for logtap."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
|
|
8
|
+
from logtap.api.dependencies import get_settings, verify_api_key
|
|
9
|
+
from logtap.models.config import Settings
|
|
10
|
+
from logtap.models.responses import FileListResponse
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("", response_model=FileListResponse)
|
|
16
|
+
async def list_files(
|
|
17
|
+
settings: Settings = Depends(get_settings),
|
|
18
|
+
_api_key: Optional[str] = Depends(verify_api_key),
|
|
19
|
+
) -> FileListResponse:
|
|
20
|
+
"""
|
|
21
|
+
List available log files in the configured log directory.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of log file names and the directory path.
|
|
25
|
+
"""
|
|
26
|
+
log_dir = settings.get_log_directory()
|
|
27
|
+
|
|
28
|
+
# Get list of files (not directories)
|
|
29
|
+
try:
|
|
30
|
+
files = [
|
|
31
|
+
f for f in os.listdir(log_dir)
|
|
32
|
+
if os.path.isfile(os.path.join(log_dir, f))
|
|
33
|
+
]
|
|
34
|
+
files.sort()
|
|
35
|
+
except OSError:
|
|
36
|
+
files = []
|
|
37
|
+
|
|
38
|
+
return FileListResponse(files=files, directory=log_dir)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Health check endpoint for logtap."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from logtap import __version__
|
|
6
|
+
from logtap.models.responses import HealthResponse
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/health", response_model=HealthResponse)
|
|
12
|
+
async def health_check() -> HealthResponse:
|
|
13
|
+
"""
|
|
14
|
+
Check the health of the logtap service.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Health status and version information.
|
|
18
|
+
"""
|
|
19
|
+
return HealthResponse(status="healthy", version=__version__)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Log query endpoints for logtap."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import aiofiles
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
|
|
9
|
+
from starlette.responses import StreamingResponse
|
|
10
|
+
|
|
11
|
+
from logtap.api.dependencies import get_settings, verify_api_key
|
|
12
|
+
from logtap.core.reader import tail_async
|
|
13
|
+
from logtap.core.search import filter_lines
|
|
14
|
+
from logtap.core.validation import is_filename_valid, is_limit_valid, is_search_term_valid
|
|
15
|
+
from logtap.models.config import Settings
|
|
16
|
+
from logtap.models.responses import LogResponse
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Error messages (matching original for backward compatibility)
|
|
22
|
+
ERROR_INVALID_FILENAME = 'Invalid filename: must not contain ".." or start with "/"'
|
|
23
|
+
ERROR_LONG_SEARCH_TERM = "Search term is too long: must be 100 characters or fewer"
|
|
24
|
+
ERROR_INVALID_LIMIT = "Invalid limit value: must be between 1 and 1000"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_filename(filename: str) -> None:
|
|
28
|
+
"""Validate filename and raise HTTPException if invalid."""
|
|
29
|
+
if not is_filename_valid(filename):
|
|
30
|
+
raise HTTPException(
|
|
31
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
32
|
+
detail=ERROR_INVALID_FILENAME,
|
|
33
|
+
)
|
|
34
|
+
# Block any filename with path separators
|
|
35
|
+
if "/" in filename or "\\" in filename:
|
|
36
|
+
raise HTTPException(
|
|
37
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
38
|
+
detail=ERROR_INVALID_FILENAME,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_filepath(filename: str, settings: Settings) -> str:
|
|
43
|
+
"""Get full filepath and validate it exists."""
|
|
44
|
+
log_dir = settings.get_log_directory()
|
|
45
|
+
filepath = os.path.join(log_dir, filename)
|
|
46
|
+
if not os.path.isfile(filepath):
|
|
47
|
+
raise HTTPException(
|
|
48
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
49
|
+
detail=f"File not found: {filepath} does not exist",
|
|
50
|
+
)
|
|
51
|
+
return filepath
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("", response_model=LogResponse)
|
|
55
|
+
async def get_logs(
|
|
56
|
+
filename: str = Query(default="syslog", description="Name of the log file to read"),
|
|
57
|
+
term: str = Query(default="", description="Substring to search for in log lines"),
|
|
58
|
+
regex: Optional[str] = Query(default=None, description="Regex pattern to match log lines"),
|
|
59
|
+
limit: int = Query(default=50, ge=1, le=1000, description="Number of lines to return (1-1000)"),
|
|
60
|
+
case_sensitive: bool = Query(default=True, description="Whether search is case-sensitive"),
|
|
61
|
+
settings: Settings = Depends(get_settings),
|
|
62
|
+
_api_key: Optional[str] = Depends(verify_api_key),
|
|
63
|
+
) -> LogResponse:
|
|
64
|
+
"""
|
|
65
|
+
Retrieve log entries from a specified log file.
|
|
66
|
+
|
|
67
|
+
This endpoint reads the last N lines from a log file and optionally
|
|
68
|
+
filters them by a search term or regex pattern.
|
|
69
|
+
"""
|
|
70
|
+
validate_filename(filename)
|
|
71
|
+
|
|
72
|
+
if term and not is_search_term_valid(term):
|
|
73
|
+
raise HTTPException(
|
|
74
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
75
|
+
detail=ERROR_LONG_SEARCH_TERM,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not is_limit_valid(limit):
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
81
|
+
detail=ERROR_INVALID_LIMIT,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
filepath = get_filepath(filename, settings)
|
|
85
|
+
lines = await tail_async(filepath, limit)
|
|
86
|
+
|
|
87
|
+
if regex:
|
|
88
|
+
lines = filter_lines(lines, regex=regex, case_sensitive=case_sensitive)
|
|
89
|
+
elif term:
|
|
90
|
+
lines = filter_lines(lines, term=term, case_sensitive=case_sensitive)
|
|
91
|
+
|
|
92
|
+
return LogResponse(lines=lines, count=len(lines), filename=filename)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/multi")
|
|
96
|
+
async def get_logs_multi(
|
|
97
|
+
filenames: str = Query(..., description="Comma-separated list of log file names"),
|
|
98
|
+
term: str = Query(default="", description="Substring to search for"),
|
|
99
|
+
regex: Optional[str] = Query(default=None, description="Regex pattern to match"),
|
|
100
|
+
limit: int = Query(default=50, ge=1, le=1000, description="Lines per file"),
|
|
101
|
+
case_sensitive: bool = Query(default=True, description="Case-sensitive search"),
|
|
102
|
+
settings: Settings = Depends(get_settings),
|
|
103
|
+
_api_key: Optional[str] = Depends(verify_api_key),
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""
|
|
106
|
+
Query multiple log files simultaneously.
|
|
107
|
+
|
|
108
|
+
Returns results grouped by filename.
|
|
109
|
+
"""
|
|
110
|
+
file_list = [f.strip() for f in filenames.split(",") if f.strip()]
|
|
111
|
+
|
|
112
|
+
if not file_list:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
115
|
+
detail="No filenames provided",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if len(file_list) > 10:
|
|
119
|
+
raise HTTPException(
|
|
120
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
121
|
+
detail="Maximum 10 files per request",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
results = {}
|
|
125
|
+
log_dir = settings.get_log_directory()
|
|
126
|
+
|
|
127
|
+
for filename in file_list:
|
|
128
|
+
try:
|
|
129
|
+
validate_filename(filename)
|
|
130
|
+
filepath = os.path.join(log_dir, filename)
|
|
131
|
+
|
|
132
|
+
if not os.path.isfile(filepath):
|
|
133
|
+
results[filename] = {"error": "File not found", "lines": []}
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
lines = await tail_async(filepath, limit)
|
|
137
|
+
|
|
138
|
+
if regex:
|
|
139
|
+
lines = filter_lines(lines, regex=regex, case_sensitive=case_sensitive)
|
|
140
|
+
elif term:
|
|
141
|
+
lines = filter_lines(lines, term=term, case_sensitive=case_sensitive)
|
|
142
|
+
|
|
143
|
+
results[filename] = {"lines": lines, "count": len(lines)}
|
|
144
|
+
|
|
145
|
+
except HTTPException as e:
|
|
146
|
+
results[filename] = {"error": e.detail, "lines": []}
|
|
147
|
+
|
|
148
|
+
return {"results": results, "files_queried": len(file_list)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.websocket("/stream")
|
|
152
|
+
async def stream_logs(
|
|
153
|
+
websocket: WebSocket,
|
|
154
|
+
filename: str = Query(default="syslog"),
|
|
155
|
+
):
|
|
156
|
+
"""
|
|
157
|
+
Stream log file changes in real-time via WebSocket.
|
|
158
|
+
|
|
159
|
+
Connect to this endpoint to receive new log lines as they are written.
|
|
160
|
+
Similar to `tail -f`.
|
|
161
|
+
"""
|
|
162
|
+
await websocket.accept()
|
|
163
|
+
|
|
164
|
+
# Get settings (can't use Depends in WebSocket easily)
|
|
165
|
+
settings = get_settings()
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
validate_filename(filename)
|
|
169
|
+
except HTTPException as e:
|
|
170
|
+
await websocket.send_json({"error": e.detail})
|
|
171
|
+
await websocket.close()
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
log_dir = settings.get_log_directory()
|
|
175
|
+
filepath = os.path.join(log_dir, filename)
|
|
176
|
+
|
|
177
|
+
if not os.path.isfile(filepath):
|
|
178
|
+
await websocket.send_json({"error": f"File not found: {filename}"})
|
|
179
|
+
await websocket.close()
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
async with aiofiles.open(filepath, mode="r", encoding="utf-8") as f:
|
|
184
|
+
# Seek to end of file
|
|
185
|
+
await f.seek(0, 2)
|
|
186
|
+
|
|
187
|
+
while True:
|
|
188
|
+
line = await f.readline()
|
|
189
|
+
if line:
|
|
190
|
+
await websocket.send_text(line.rstrip("\n"))
|
|
191
|
+
else:
|
|
192
|
+
# No new content, wait a bit
|
|
193
|
+
await asyncio.sleep(0.1)
|
|
194
|
+
|
|
195
|
+
# Check if client is still connected
|
|
196
|
+
try:
|
|
197
|
+
# Non-blocking receive to check connection
|
|
198
|
+
await asyncio.wait_for(websocket.receive_text(), timeout=0.01)
|
|
199
|
+
except asyncio.TimeoutError:
|
|
200
|
+
# No message, continue streaming
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
except WebSocketDisconnect:
|
|
204
|
+
pass
|
|
205
|
+
except Exception as e:
|
|
206
|
+
try:
|
|
207
|
+
await websocket.send_json({"error": str(e)})
|
|
208
|
+
except Exception:
|
|
209
|
+
# Connection already closed, ignore
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.get("/sse")
|
|
214
|
+
async def stream_logs_sse(
|
|
215
|
+
filename: str = Query(default="syslog", description="Log file to stream"),
|
|
216
|
+
settings: Settings = Depends(get_settings),
|
|
217
|
+
_api_key: Optional[str] = Depends(verify_api_key),
|
|
218
|
+
):
|
|
219
|
+
"""
|
|
220
|
+
Stream log file changes via Server-Sent Events (SSE).
|
|
221
|
+
|
|
222
|
+
Alternative to WebSocket for simpler clients.
|
|
223
|
+
"""
|
|
224
|
+
validate_filename(filename)
|
|
225
|
+
filepath = get_filepath(filename, settings)
|
|
226
|
+
|
|
227
|
+
async def event_generator():
|
|
228
|
+
async with aiofiles.open(filepath, mode="r", encoding="utf-8") as f:
|
|
229
|
+
# Seek to end
|
|
230
|
+
await f.seek(0, 2)
|
|
231
|
+
|
|
232
|
+
while True:
|
|
233
|
+
line = await f.readline()
|
|
234
|
+
if line:
|
|
235
|
+
# SSE format: data: <content>\n\n
|
|
236
|
+
yield f"data: {line.rstrip()}\n\n"
|
|
237
|
+
else:
|
|
238
|
+
# Send heartbeat to keep connection alive
|
|
239
|
+
yield ": heartbeat\n\n"
|
|
240
|
+
await asyncio.sleep(1)
|
|
241
|
+
|
|
242
|
+
return StreamingResponse(
|
|
243
|
+
event_generator(),
|
|
244
|
+
media_type="text/event-stream",
|
|
245
|
+
headers={
|
|
246
|
+
"Cache-Control": "no-cache",
|
|
247
|
+
"Connection": "keep-alive",
|
|
248
|
+
},
|
|
249
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Parsed log endpoints for logtap - with format detection and severity filtering."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
7
|
+
|
|
8
|
+
from logtap.api.dependencies import get_settings, verify_api_key
|
|
9
|
+
from logtap.core.parsers import AutoParser, LogLevel
|
|
10
|
+
from logtap.core.reader import tail_async
|
|
11
|
+
from logtap.core.search import filter_entries
|
|
12
|
+
from logtap.models.config import Settings
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("")
|
|
18
|
+
async def get_parsed_logs(
|
|
19
|
+
filename: str = Query(default="syslog", description="Name of the log file to read"),
|
|
20
|
+
term: str = Query(default="", description="Substring to search for"),
|
|
21
|
+
regex: Optional[str] = Query(default=None, description="Regex pattern to match"),
|
|
22
|
+
limit: int = Query(default=50, ge=1, le=1000, description="Number of lines"),
|
|
23
|
+
level: Optional[str] = Query(
|
|
24
|
+
default=None,
|
|
25
|
+
description="Minimum severity level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
|
26
|
+
),
|
|
27
|
+
levels: Optional[str] = Query(
|
|
28
|
+
default=None,
|
|
29
|
+
description="Comma-separated list of specific levels to include",
|
|
30
|
+
),
|
|
31
|
+
case_sensitive: bool = Query(default=True),
|
|
32
|
+
settings: Settings = Depends(get_settings),
|
|
33
|
+
_api_key: Optional[str] = Depends(verify_api_key),
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""
|
|
36
|
+
Retrieve and parse log entries with format auto-detection.
|
|
37
|
+
|
|
38
|
+
Returns structured log data with extracted fields like timestamp,
|
|
39
|
+
severity level, source, and message. Supports filtering by severity.
|
|
40
|
+
|
|
41
|
+
Supported formats: syslog, JSON, nginx, apache (auto-detected).
|
|
42
|
+
"""
|
|
43
|
+
# Validate filename
|
|
44
|
+
if ".." in filename or filename.startswith("/") or "/" in filename or "\\" in filename:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
47
|
+
detail='Invalid filename: must not contain ".." or start with "/"',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Build file path
|
|
51
|
+
log_dir = settings.get_log_directory()
|
|
52
|
+
filepath = os.path.join(log_dir, filename)
|
|
53
|
+
|
|
54
|
+
if not os.path.isfile(filepath):
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
57
|
+
detail=f"File not found: {filepath} does not exist",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Read file lines
|
|
61
|
+
lines = await tail_async(filepath, limit)
|
|
62
|
+
|
|
63
|
+
# Parse lines
|
|
64
|
+
parser = AutoParser()
|
|
65
|
+
entries = parser.parse_many(lines)
|
|
66
|
+
|
|
67
|
+
# Parse level filter
|
|
68
|
+
min_level = None
|
|
69
|
+
if level:
|
|
70
|
+
min_level = LogLevel.from_string(level)
|
|
71
|
+
if not min_level:
|
|
72
|
+
valid = "DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY"
|
|
73
|
+
raise HTTPException(
|
|
74
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
75
|
+
detail=f"Invalid level: {level}. Valid levels: {valid}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Parse levels filter
|
|
79
|
+
level_list = None
|
|
80
|
+
if levels:
|
|
81
|
+
level_list = []
|
|
82
|
+
for lvl in levels.split(","):
|
|
83
|
+
parsed = LogLevel.from_string(lvl.strip())
|
|
84
|
+
if parsed:
|
|
85
|
+
level_list.append(parsed)
|
|
86
|
+
|
|
87
|
+
# Apply filters
|
|
88
|
+
filtered = filter_entries(
|
|
89
|
+
entries,
|
|
90
|
+
term=term if term else None,
|
|
91
|
+
regex=regex,
|
|
92
|
+
min_level=min_level,
|
|
93
|
+
levels=level_list,
|
|
94
|
+
case_sensitive=case_sensitive,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"entries": [e.to_dict() for e in filtered],
|
|
99
|
+
"count": len(filtered),
|
|
100
|
+
"filename": filename,
|
|
101
|
+
"format": parser.name,
|
|
102
|
+
}
|
logtap/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands for logtap."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands for logtap."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Files command for logtap CLI - list available log files."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def files(
|
|
13
|
+
server: str = typer.Option(
|
|
14
|
+
"http://localhost:8000",
|
|
15
|
+
"--server",
|
|
16
|
+
"-s",
|
|
17
|
+
help="URL of the logtap server.",
|
|
18
|
+
envvar="LOGTAP_SERVER",
|
|
19
|
+
),
|
|
20
|
+
api_key: Optional[str] = typer.Option(
|
|
21
|
+
None,
|
|
22
|
+
"--api-key",
|
|
23
|
+
"-k",
|
|
24
|
+
help="API key for authentication.",
|
|
25
|
+
envvar="LOGTAP_API_KEY",
|
|
26
|
+
),
|
|
27
|
+
output: str = typer.Option(
|
|
28
|
+
"pretty",
|
|
29
|
+
"--output",
|
|
30
|
+
"-o",
|
|
31
|
+
help="Output format: pretty, json, plain.",
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
List available log files on the server.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
logtap files
|
|
39
|
+
logtap files --server http://myserver:8000
|
|
40
|
+
"""
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
headers = {}
|
|
44
|
+
if api_key:
|
|
45
|
+
headers["X-API-Key"] = api_key
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with httpx.Client(timeout=30.0) as client:
|
|
49
|
+
response = client.get(f"{server}/files", headers=headers)
|
|
50
|
+
|
|
51
|
+
if response.status_code != 200:
|
|
52
|
+
error_detail = response.json().get("detail", response.text)
|
|
53
|
+
console.print(f"[bold red]Error:[/bold red] {error_detail}")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
data = response.json()
|
|
57
|
+
files_list = data.get("files", [])
|
|
58
|
+
directory = data.get("directory", "")
|
|
59
|
+
|
|
60
|
+
# Format output
|
|
61
|
+
if output == "json":
|
|
62
|
+
console.print_json(data=data)
|
|
63
|
+
elif output == "plain":
|
|
64
|
+
for f in files_list:
|
|
65
|
+
console.print(f)
|
|
66
|
+
else:
|
|
67
|
+
# Pretty output with table
|
|
68
|
+
if files_list:
|
|
69
|
+
table = Table(title=f"Log files in {directory}")
|
|
70
|
+
table.add_column("Filename", style="cyan")
|
|
71
|
+
|
|
72
|
+
for f in files_list:
|
|
73
|
+
table.add_row(f)
|
|
74
|
+
|
|
75
|
+
console.print(table)
|
|
76
|
+
console.print(f"\n[dim]{len(files_list)} files found[/dim]")
|
|
77
|
+
else:
|
|
78
|
+
console.print(f"[dim]No log files found in {directory}[/dim]")
|
|
79
|
+
|
|
80
|
+
except httpx.ConnectError:
|
|
81
|
+
console.print(f"[bold red]Error:[/bold red] Could not connect to {server}")
|
|
82
|
+
console.print("[dim]Is the logtap server running? Start it with 'logtap serve'[/dim]")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
86
|
+
raise typer.Exit(1)
|