|
7 | 7 |
|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
| 10 | +import asyncio |
10 | 11 | import logging |
| 12 | +import subprocess |
11 | 13 | from contextlib import asynccontextmanager |
12 | 14 | from threading import RLock |
13 | 15 | from typing import Annotated, AsyncGenerator, Optional |
14 | 16 |
|
15 | | -from fastapi import Depends, FastAPI, HTTPException, Security, status |
| 17 | +from fastapi import Depends, FastAPI, HTTPException, Query, Security, status |
| 18 | +from fastapi.responses import StreamingResponse as FastAPIStreamingResponse |
16 | 19 | from fastapi.security import APIKeyHeader |
17 | 20 | from pydantic import BaseModel, Field |
18 | 21 |
|
@@ -192,7 +195,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: |
192 | 195 | app = FastAPI( |
193 | 196 | title="Pi Camera Service", |
194 | 197 | description="API for controlling Raspberry Pi camera and streaming to MediaMTX via RTSP", |
195 | | - version="2.5.0", |
| 198 | + version="2.8.1", |
196 | 199 | lifespan=lifespan, |
197 | 200 | ) |
198 | 201 |
|
@@ -489,6 +492,13 @@ class SystemStatusResponse(BaseModel): |
489 | 492 | throttled: Optional[dict] = Field(None, description="Throttling status (Pi-specific)") |
490 | 493 |
|
491 | 494 |
|
| 495 | +class LogsResponse(BaseModel): |
| 496 | + """Logs response model.""" |
| 497 | + logs: list[str] = Field(..., description="Log lines") |
| 498 | + total_lines: int = Field(..., description="Total number of lines returned") |
| 499 | + service: str = Field("pi-camera-service", description="Service name") |
| 500 | + |
| 501 | + |
492 | 502 | # ========== Exception Handlers ========== |
493 | 503 |
|
494 | 504 | @app.exception_handler(InvalidParameterError) |
@@ -548,7 +558,7 @@ def health_check() -> HealthResponse: |
548 | 558 | status="healthy" if camera_controller is not None else "initializing", |
549 | 559 | camera_configured=camera_controller._configured if camera_controller else False, |
550 | 560 | streaming_active=streaming_manager.is_streaming() if streaming_manager else False, |
551 | | - version="2.5.0", |
| 561 | + version="2.8.1", |
552 | 562 | ) |
553 | 563 |
|
554 | 564 |
|
@@ -1981,3 +1991,200 @@ def get_system_status( |
1981 | 1991 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
1982 | 1992 | detail="Failed to get system status", |
1983 | 1993 | ) |
| 1994 | + |
| 1995 | + |
| 1996 | +@app.get( |
| 1997 | + "/v1/system/logs", |
| 1998 | + response_model=LogsResponse, |
| 1999 | + summary="Get service logs", |
| 2000 | + description="Get logs from the pi-camera-service with optional filtering by lines, level, and search pattern", |
| 2001 | + tags=["System"], |
| 2002 | +) |
| 2003 | +def get_system_logs( |
| 2004 | + lines: Annotated[int, Query(ge=1, le=10000, description="Number of log lines to retrieve")] = 100, |
| 2005 | + level: Annotated[Optional[str], Query(description="Filter by log level (INFO, WARNING, ERROR)")] = None, |
| 2006 | + search: Annotated[Optional[str], Query(description="Search pattern to filter logs")] = None, |
| 2007 | + _: Annotated[None, Depends(verify_api_key)] = None, |
| 2008 | +) -> LogsResponse: |
| 2009 | + """ |
| 2010 | + Get service logs with optional filtering. |
| 2011 | +
|
| 2012 | + Retrieves logs from the pi-camera-service systemd service using journalctl. |
| 2013 | +
|
| 2014 | + Query Parameters: |
| 2015 | + - lines: Number of log lines to retrieve (1-10000, default: 100) |
| 2016 | + - level: Filter by log level (INFO, WARNING, ERROR) |
| 2017 | + - search: Search for specific pattern in logs |
| 2018 | +
|
| 2019 | + Examples: |
| 2020 | + - /v1/system/logs?lines=50 - Get last 50 log lines |
| 2021 | + - /v1/system/logs?level=ERROR - Get only ERROR level logs |
| 2022 | + - /v1/system/logs?search=resolution - Search for "resolution" in logs |
| 2023 | + - /v1/system/logs?lines=200&level=ERROR&search=camera - Combined filters |
| 2024 | +
|
| 2025 | + Returns: |
| 2026 | + LogsResponse: Log lines and metadata |
| 2027 | +
|
| 2028 | + Raises: |
| 2029 | + HTTPException: If log retrieval fails |
| 2030 | + """ |
| 2031 | + logger.debug(f"Getting system logs: lines={lines}, level={level}, search={search}") |
| 2032 | + |
| 2033 | + try: |
| 2034 | + # Build journalctl command (no sudo needed - journalctl allows reading own service logs) |
| 2035 | + cmd = ["journalctl", "-u", "pi-camera-service", "-n", str(lines), "--no-pager"] |
| 2036 | + |
| 2037 | + # Execute journalctl |
| 2038 | + result = subprocess.run( |
| 2039 | + cmd, |
| 2040 | + capture_output=True, |
| 2041 | + text=True, |
| 2042 | + timeout=10 |
| 2043 | + ) |
| 2044 | + |
| 2045 | + if result.returncode != 0: |
| 2046 | + raise HTTPException( |
| 2047 | + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 2048 | + detail=f"Failed to retrieve logs: {result.stderr}", |
| 2049 | + ) |
| 2050 | + |
| 2051 | + # Split logs into lines |
| 2052 | + log_lines = result.stdout.strip().split('\n') if result.stdout else [] |
| 2053 | + |
| 2054 | + # Apply level filter if specified |
| 2055 | + if level: |
| 2056 | + level_upper = level.upper() |
| 2057 | + log_lines = [line for line in log_lines if level_upper in line] |
| 2058 | + |
| 2059 | + # Apply search filter if specified |
| 2060 | + if search: |
| 2061 | + log_lines = [line for line in log_lines if search in line] |
| 2062 | + |
| 2063 | + return LogsResponse( |
| 2064 | + logs=log_lines, |
| 2065 | + total_lines=len(log_lines), |
| 2066 | + service="pi-camera-service" |
| 2067 | + ) |
| 2068 | + |
| 2069 | + except subprocess.TimeoutExpired: |
| 2070 | + logger.error("Timeout while retrieving logs") |
| 2071 | + raise HTTPException( |
| 2072 | + status_code=status.HTTP_504_GATEWAY_TIMEOUT, |
| 2073 | + detail="Log retrieval timed out", |
| 2074 | + ) |
| 2075 | + except Exception as e: |
| 2076 | + logger.error(f"Error getting system logs: {e}") |
| 2077 | + raise HTTPException( |
| 2078 | + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 2079 | + detail=f"Failed to retrieve logs: {str(e)}", |
| 2080 | + ) |
| 2081 | + |
| 2082 | + |
| 2083 | +@app.get( |
| 2084 | + "/v1/system/logs/stream", |
| 2085 | + summary="Stream service logs in real-time", |
| 2086 | + description="Stream logs from the pi-camera-service in real-time using Server-Sent Events (SSE)", |
| 2087 | + tags=["System"], |
| 2088 | +) |
| 2089 | +async def stream_system_logs( |
| 2090 | + level: Annotated[Optional[str], Query(description="Filter by log level (INFO, WARNING, ERROR)")] = None, |
| 2091 | + search: Annotated[Optional[str], Query(description="Search pattern to filter logs")] = None, |
| 2092 | + _: Annotated[None, Depends(verify_api_key)] = None, |
| 2093 | +): |
| 2094 | + """ |
| 2095 | + Stream service logs in real-time using Server-Sent Events (SSE). |
| 2096 | +
|
| 2097 | + This endpoint provides a continuous stream of log entries as they are generated. |
| 2098 | + The connection will remain open until the client disconnects. |
| 2099 | +
|
| 2100 | + Query Parameters: |
| 2101 | + - level: Filter by log level (INFO, WARNING, ERROR) |
| 2102 | + - search: Search for specific pattern in logs |
| 2103 | +
|
| 2104 | + Usage: |
| 2105 | + - Use EventSource in JavaScript or SSE client libraries |
| 2106 | + - Each event contains a single log line |
| 2107 | + - Connection stays open for continuous streaming |
| 2108 | +
|
| 2109 | + Example (JavaScript): |
| 2110 | + ```javascript |
| 2111 | + const eventSource = new EventSource('/v1/system/logs/stream?level=ERROR'); |
| 2112 | + eventSource.onmessage = (event) => { |
| 2113 | + console.log('Log:', event.data); |
| 2114 | + }; |
| 2115 | + ``` |
| 2116 | +
|
| 2117 | + Returns: |
| 2118 | + StreamingResponse: SSE stream of log entries |
| 2119 | +
|
| 2120 | + Raises: |
| 2121 | + HTTPException: If log streaming fails |
| 2122 | + """ |
| 2123 | + logger.debug(f"Starting log stream: level={level}, search={search}") |
| 2124 | + |
| 2125 | + async def log_generator(): |
| 2126 | + """Generate log events as they arrive.""" |
| 2127 | + process = None |
| 2128 | + try: |
| 2129 | + # Start journalctl in follow mode (no sudo needed - journalctl allows reading own service logs) |
| 2130 | + cmd = ["journalctl", "-u", "pi-camera-service", "-f", "--no-pager", "-n", "0"] |
| 2131 | + |
| 2132 | + # Use asyncio subprocess for non-blocking I/O |
| 2133 | + process = await asyncio.create_subprocess_exec( |
| 2134 | + *cmd, |
| 2135 | + stdout=asyncio.subprocess.PIPE, |
| 2136 | + stderr=asyncio.subprocess.PIPE |
| 2137 | + ) |
| 2138 | + |
| 2139 | + # Read lines as they arrive |
| 2140 | + while True: |
| 2141 | + # Read line asynchronously |
| 2142 | + line_bytes = await process.stdout.readline() |
| 2143 | + |
| 2144 | + if not line_bytes: |
| 2145 | + # Process ended |
| 2146 | + break |
| 2147 | + |
| 2148 | + line = line_bytes.decode('utf-8').strip() |
| 2149 | + |
| 2150 | + if not line: |
| 2151 | + continue |
| 2152 | + |
| 2153 | + # Apply level filter if specified |
| 2154 | + if level and level.upper() not in line: |
| 2155 | + continue |
| 2156 | + |
| 2157 | + # Apply search filter if specified |
| 2158 | + if search and search not in line: |
| 2159 | + continue |
| 2160 | + |
| 2161 | + # Send as SSE event |
| 2162 | + yield f"data: {line}\n\n" |
| 2163 | + |
| 2164 | + except asyncio.CancelledError: |
| 2165 | + # Client disconnected |
| 2166 | + logger.debug("Log stream cancelled by client") |
| 2167 | + except Exception as e: |
| 2168 | + logger.error(f"Error in log stream: {e}") |
| 2169 | + yield f"data: ERROR: {str(e)}\n\n" |
| 2170 | + finally: |
| 2171 | + # Clean up process |
| 2172 | + if process: |
| 2173 | + try: |
| 2174 | + process.terminate() |
| 2175 | + await asyncio.wait_for(process.wait(), timeout=2.0) |
| 2176 | + except asyncio.TimeoutError: |
| 2177 | + process.kill() |
| 2178 | + await process.wait() |
| 2179 | + except Exception as e: |
| 2180 | + logger.error(f"Error cleaning up log stream process: {e}") |
| 2181 | + |
| 2182 | + return FastAPIStreamingResponse( |
| 2183 | + log_generator(), |
| 2184 | + media_type="text/event-stream", |
| 2185 | + headers={ |
| 2186 | + "Cache-Control": "no-cache", |
| 2187 | + "Connection": "keep-alive", |
| 2188 | + "X-Accel-Buffering": "no", # Disable nginx buffering |
| 2189 | + } |
| 2190 | + ) |
0 commit comments