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:
2026-07-20 21:41:34 +03:00
parent 49a566359a
commit 7bfc5553cf
44 changed files with 2808 additions and 412 deletions

View File

@@ -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).