feat: live app log console, cross-filter, JSONL export
Backend:
- /ws/app-logs — real-time app/cot log stream (raw JSONL)
- /api/logs/recent — REST snapshot of ring buffer
- GET /tasks/{id}/logs/export — streaming JSONL export with CoT parse + redaction
- Thread-safe ring buffer (seq-based polling) replaces unsafe asyncio.Queue
- GIL-friendly multi-row Core insert for log persistence
- task_id ContextVar propagates into CotJsonFormatter for CoT correlation
- Hot-apply logging level on settings update (FR-005)
- Buffer trim under DEBUG floods; drop DEBUG first, preserve ERROR/WARNING
- List projection (include_result=False) keeps reports list slim
- Security event consolidated to single REASON atom
Frontend:
- ReportsLogModel + ReportsLogPanel — full live JSONL console
- Cross-filter pinning: Tasks → Logs tab with task chip badges
- LogEntryRow — CoT-aware rendering (marker icons, expandable payload)
- TaskFilterChip, taskChipMeta — scannable type/id/env chips
- Global drawer push via CSS variable (lg+ padding, not overlay)
- i18n en/ru for all log console strings
- Ctrl+A in log panel selects only log lines (window-level handler)
QA fixes:
- Svelte 5 reactivity: SvelteDate/SvelteSet/SvelteURLSearchParams
- Fix seed_trace_id shadowing (F823) in lifecycle.py
- Remove dead code (selectedEnvironment, goToReportsPage)
- Add @BRIEF to C2 test functions, missing {#each} keys
- Remove unused import json as _json from app.py
All 3200+ frontend tests pass; backend lint clean.
This commit is contained in:
@@ -26,7 +26,15 @@ import uuid
|
||||
# project_root is used for static files mounting
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import (
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
status,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -72,7 +80,12 @@ from .core.database import AuthSessionLocal, init_db
|
||||
from .core.encryption_key import ensure_encryption_key
|
||||
from .core.logger import belief_scope, logger
|
||||
from .core.utils.network import NetworkError
|
||||
from .dependencies import get_async_job_runner, get_scheduler_service, get_task_manager
|
||||
from .dependencies import (
|
||||
get_async_job_runner,
|
||||
get_current_user,
|
||||
get_scheduler_service,
|
||||
get_task_manager,
|
||||
)
|
||||
from .models.auth import Role, User
|
||||
|
||||
|
||||
@@ -489,6 +502,59 @@ app.include_router(health.router)
|
||||
app.include_router(encryption_health.router)
|
||||
app.include_router(translate.router)
|
||||
app.include_router(validation_tasks, prefix="/api/validation-tasks", tags=["Validation Tasks"])
|
||||
|
||||
|
||||
# #region get_recent_app_logs [C:3] [TYPE Function] [SEMANTICS api,logs,app,recent]
|
||||
# @BRIEF REST snapshot of the process-wide app/cot log ring buffer (raw JSON lines).
|
||||
@app.get("/api/logs/recent")
|
||||
async def get_recent_app_logs(
|
||||
limit: int = 500,
|
||||
level: str | None = None,
|
||||
task_id: str | None = None,
|
||||
after_seq: int = 0,
|
||||
_user=Depends(get_current_user),
|
||||
):
|
||||
"""Return recent application log lines for the Reports live console.
|
||||
|
||||
Each item: {seq, level, raw, timestamp, logger}.
|
||||
`raw` is CotJsonFormatter single-line JSON (JSONL-compatible).
|
||||
Optional task_id: comma-separated filter — lines whose raw text contains the id.
|
||||
"""
|
||||
from src.core.logger import get_app_log_handler
|
||||
|
||||
limit = max(1, min(int(limit or 500), 3000))
|
||||
handler = get_app_log_handler()
|
||||
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
|
||||
min_level = level_hierarchy.get((level or "").upper(), 0) if level else 0
|
||||
task_ids = {t.strip() for t in (task_id or "").split(",") if t.strip()}
|
||||
|
||||
if after_seq > 0:
|
||||
lines, current = handler.get_since(after_seq, limit=limit)
|
||||
else:
|
||||
lines = handler.get_recent_logs(limit=limit)
|
||||
current = handler.current_seq()
|
||||
|
||||
items = []
|
||||
for line in lines:
|
||||
lv = level_hierarchy.get(str(line.level).upper(), 0)
|
||||
if lv < min_level:
|
||||
continue
|
||||
raw = line.raw or ""
|
||||
if task_ids and not any(tid in raw for tid in task_ids):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"seq": line.seq,
|
||||
"level": line.level,
|
||||
"raw": raw,
|
||||
"timestamp": line.timestamp.isoformat() if line.timestamp else None,
|
||||
"logger": line.logger_name,
|
||||
}
|
||||
)
|
||||
return {"items": items, "current_seq": current, "capacity": handler.capacity}
|
||||
|
||||
|
||||
# #endregion get_recent_app_logs
|
||||
app.include_router(tools.router, tags=["Tools"])
|
||||
app.include_router(maintenance.maintenance_router)
|
||||
|
||||
@@ -878,9 +944,91 @@ async def task_events_websocket(websocket: WebSocket):
|
||||
logger.reflect("Released global task events subscription")
|
||||
|
||||
|
||||
|
||||
# #endregion task_events_websocket
|
||||
|
||||
|
||||
# #region app_logs_websocket [C:4] [TYPE Function] [SEMANTICS websocket,logs,app,realtime,jsonl]
|
||||
# @Namespace Module
|
||||
# @BRIEF Live tail of process-wide app/cot log ring buffer (raw JSON lines).
|
||||
# @PRE WebSocket authenticated via token query param.
|
||||
# @POST Streams {seq, level, raw, timestamp} for new log lines until disconnect.
|
||||
# @RATIONALE Reports Logs tab needs full server stream, not only task-scoped logs.
|
||||
# @REJECTED Binding console solely to task_id — operators need unfiltered live server output.
|
||||
@app.websocket("/ws/app-logs")
|
||||
async def app_logs_websocket(
|
||||
websocket: WebSocket,
|
||||
level: str | None = None,
|
||||
task_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Stream application CoT/JSON log lines from the in-process ring buffer.
|
||||
Query params:
|
||||
token: JWT (required)
|
||||
level: min level (DEBUG|INFO|WARNING|ERROR)
|
||||
task_id: optional comma-separated task ids — only lines whose raw JSON contains matching task_id
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from src.core.logger import get_app_log_handler
|
||||
|
||||
_set_websocket_trace_id(websocket)
|
||||
with belief_scope("app_logs_websocket"):
|
||||
if not await _authenticate_websocket(websocket, "ws/app-logs"):
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
await websocket.accept()
|
||||
handler = get_app_log_handler()
|
||||
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
|
||||
min_level = level_hierarchy.get((level or "").upper(), 0) if level else 0
|
||||
task_ids = {t.strip() for t in (task_id or "").split(",") if t.strip()}
|
||||
|
||||
def _match(line) -> bool:
|
||||
lv = level_hierarchy.get(str(line.level).upper(), 0)
|
||||
if lv < min_level:
|
||||
return False
|
||||
if not task_ids:
|
||||
return True
|
||||
raw = line.raw or ""
|
||||
# Fast path: substring match for task uuid in JSON
|
||||
return any(tid in raw for tid in task_ids)
|
||||
|
||||
# Snapshot recent, then poll for new seq
|
||||
recent = handler.get_recent_logs(limit=500)
|
||||
last_seq = handler.current_seq()
|
||||
for line in recent:
|
||||
if _match(line):
|
||||
await websocket.send_json({
|
||||
"seq": line.seq,
|
||||
"level": line.level,
|
||||
"raw": line.raw,
|
||||
"timestamp": line.timestamp.isoformat() if line.timestamp else None,
|
||||
"logger": line.logger_name,
|
||||
})
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(0.25)
|
||||
newer, last_seq = handler.get_since(last_seq, limit=500)
|
||||
for line in newer:
|
||||
if _match(line):
|
||||
await websocket.send_json({
|
||||
"seq": line.seq,
|
||||
"level": line.level,
|
||||
"raw": line.raw,
|
||||
"timestamp": line.timestamp.isoformat() if line.timestamp else None,
|
||||
"logger": line.logger_name,
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.explore("App log stream failed", error=str(exc))
|
||||
raise
|
||||
|
||||
|
||||
# #endregion app_logs_websocket
|
||||
|
||||
|
||||
|
||||
# #region maintenance_events_websocket [C:4] [TYPE Function]
|
||||
# @ingroup Module
|
||||
# @BRIEF WebSocket endpoint for maintenance events (created/ended/banner changes).
|
||||
|
||||
Reference in New Issue
Block a user