524 lines
17 KiB
Python
524 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import ROOT_DIR
|
|
from app.core.security import get_current_user
|
|
from app.db.session import get_db
|
|
from app.models.system_log import AuditLog, SystemLog
|
|
from app.models.user import User
|
|
from app.services.persistent_logs import record_audit_log, record_system_log
|
|
from app.services.system_control import (
|
|
build_task_id,
|
|
clear_active_task_id,
|
|
get_active_task_id,
|
|
get_allowed_command,
|
|
get_runner_script_path,
|
|
is_task_stale,
|
|
get_task_logs,
|
|
require_super_admin,
|
|
serialize_task,
|
|
set_active_task_id,
|
|
upsert_task_state,
|
|
)
|
|
from app.services.system_logs import (
|
|
DEFAULT_LOG_LINE_LIMIT,
|
|
MAX_LOG_LINE_LIMIT,
|
|
SUPPORTED_LOG_LEVELS,
|
|
append_buffer_log,
|
|
list_log_sources,
|
|
normalize_log_level,
|
|
read_log_snapshot,
|
|
)
|
|
from app.services.earth_layer_cache import earth_layer_cache
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class RestartTaskCreate(BaseModel):
|
|
action: str
|
|
|
|
|
|
class RestartTaskResponse(BaseModel):
|
|
task_id: str
|
|
action: str
|
|
status: str
|
|
stage: str
|
|
message: str
|
|
created_at: str
|
|
updated_at: str
|
|
requested_by: dict[str, object] | None = None
|
|
|
|
|
|
class RestartTaskLogsResponse(BaseModel):
|
|
task_id: str
|
|
lines: list[str]
|
|
|
|
|
|
class SystemLogSourceSummary(BaseModel):
|
|
source_id: str
|
|
name: str
|
|
kind: str
|
|
location: str
|
|
description: str
|
|
category: str
|
|
status: str
|
|
|
|
|
|
class SystemLogSourcesResponse(BaseModel):
|
|
items: list[SystemLogSourceSummary]
|
|
|
|
|
|
class SystemLogDailyMarker(BaseModel):
|
|
date_token: str
|
|
total: int
|
|
dominant_level: str
|
|
|
|
|
|
class SystemLogSnapshotResponse(BaseModel):
|
|
source_id: str
|
|
name: str
|
|
kind: str
|
|
location: str
|
|
description: str
|
|
category: str
|
|
status: str
|
|
level: str
|
|
selected_levels: list[str] = []
|
|
search_query: str = ""
|
|
available_levels: list[str]
|
|
daily_markers: list[SystemLogDailyMarker] = []
|
|
line_limit: int
|
|
line_count: int
|
|
lines: list[str]
|
|
|
|
|
|
class EarthClientLogEventCreate(BaseModel):
|
|
level: str = "error"
|
|
message: str
|
|
category: str | None = None
|
|
url: str | None = None
|
|
module: str | None = None
|
|
detail: str | None = None
|
|
|
|
|
|
class EarthClientLogEventResponse(BaseModel):
|
|
accepted: bool
|
|
source_id: str
|
|
level: str
|
|
|
|
|
|
class EarthLayerCacheStatusResponse(BaseModel):
|
|
prefix: str
|
|
key_count: int
|
|
memory_bytes: int
|
|
layers: dict[str, dict[str, int]]
|
|
|
|
|
|
class EarthLayerCacheClearResponse(BaseModel):
|
|
deleted: int
|
|
|
|
|
|
def ensure_super_admin(current_user: User) -> None:
|
|
if not require_super_admin(current_user.role):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only super_admin can restart services",
|
|
)
|
|
|
|
|
|
def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
|
if raw_value in {None, ""}:
|
|
return None
|
|
try:
|
|
return datetime.strptime(raw_value, "%Y-%m-%d").date().isoformat()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"{field_name} must be in YYYY-MM-DD format",
|
|
) from exc
|
|
|
|
|
|
@router.get("/cache/earth-layers", response_model=EarthLayerCacheStatusResponse)
|
|
async def get_earth_layer_cache_status(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
try:
|
|
return earth_layer_cache.status()
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Unable to read Earth layer cache status: {exc}",
|
|
) from exc
|
|
|
|
|
|
@router.delete("/cache/earth-layers", response_model=EarthLayerCacheClearResponse)
|
|
async def clear_earth_layer_cache(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
try:
|
|
return {"deleted": earth_layer_cache.delete_pattern()}
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Unable to clear Earth layer cache: {exc}",
|
|
) from exc
|
|
|
|
|
|
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
|
async def create_restart_task(
|
|
payload: RestartTaskCreate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
|
|
command = get_allowed_command(payload.action)
|
|
if command is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Unsupported system action",
|
|
)
|
|
|
|
active_task_id = get_active_task_id()
|
|
if active_task_id:
|
|
active_task = serialize_task(active_task_id)
|
|
if active_task and is_task_stale(active_task):
|
|
upsert_task_state(
|
|
active_task_id,
|
|
status="failed",
|
|
stage="failed",
|
|
message="Previous restart task became stale and was released",
|
|
)
|
|
clear_active_task_id(active_task_id)
|
|
elif active_task and active_task.get("status") in {"queued", "running"}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Another restart task is already in progress",
|
|
)
|
|
|
|
task_id = build_task_id()
|
|
requested_by = {"id": current_user.id, "username": current_user.username}
|
|
task_state = upsert_task_state(
|
|
task_id,
|
|
action=payload.action,
|
|
status="queued",
|
|
stage="accepted",
|
|
message="Restart task accepted",
|
|
requested_by=requested_by,
|
|
)
|
|
set_active_task_id(task_id)
|
|
|
|
env = os.environ.copy()
|
|
backend_path = str(ROOT_DIR / "backend")
|
|
existing_pythonpath = env.get("PYTHONPATH", "")
|
|
env["PYTHONPATH"] = (
|
|
f"{backend_path}{os.pathsep}{existing_pythonpath}"
|
|
if existing_pythonpath
|
|
else backend_path
|
|
)
|
|
|
|
try:
|
|
subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
str(get_runner_script_path()),
|
|
"--task-id",
|
|
task_id,
|
|
"--action",
|
|
payload.action,
|
|
],
|
|
cwd=str(ROOT_DIR),
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except OSError as exc:
|
|
task_state = upsert_task_state(
|
|
task_id,
|
|
action=payload.action,
|
|
status="failed",
|
|
stage="failed",
|
|
message=f"Unable to start restart runner: {exc}",
|
|
requested_by=requested_by,
|
|
)
|
|
clear_active_task_id(task_id)
|
|
await record_audit_log(
|
|
action="system.restart_task.requested",
|
|
actor_id=current_user.id,
|
|
actor_name=current_user.username,
|
|
target_type="restart_task",
|
|
target_id=task_id,
|
|
result="failed",
|
|
ip=request.client.host if request.client else None,
|
|
details={"action": payload.action, "message": task_state["message"]},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=task_state["message"],
|
|
) from exc
|
|
|
|
await record_audit_log(
|
|
action="system.restart_task.requested",
|
|
actor_id=current_user.id,
|
|
actor_name=current_user.username,
|
|
target_type="restart_task",
|
|
target_id=task_id,
|
|
result="accepted",
|
|
ip=request.client.host if request.client else None,
|
|
details={"action": payload.action},
|
|
)
|
|
return task_state
|
|
|
|
|
|
@router.get("/restart-tasks/{task_id}", response_model=RestartTaskResponse)
|
|
async def get_restart_task(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
|
|
task = serialize_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
|
return task
|
|
|
|
|
|
@router.get("/restart-tasks/{task_id}/logs", response_model=RestartTaskLogsResponse)
|
|
async def get_restart_task_logs(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
|
|
task = serialize_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
|
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
|
|
|
|
|
@router.get("/logs/sources", response_model=SystemLogSourcesResponse)
|
|
async def get_system_log_sources(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
return {
|
|
"items": [
|
|
*list_log_sources(),
|
|
{
|
|
"source_id": "system-db",
|
|
"name": "系统事件",
|
|
"kind": "database",
|
|
"location": "table://system_logs",
|
|
"description": "后端持久化系统事件、AI 和采集器操作日志。",
|
|
"category": "database",
|
|
"status": "ok",
|
|
},
|
|
{
|
|
"source_id": "audit-db",
|
|
"name": "审计事件",
|
|
"kind": "database",
|
|
"location": "table://audit_logs",
|
|
"description": "管理员敏感操作和密钥 reveal 审计记录。",
|
|
"category": "audit",
|
|
"status": "ok",
|
|
},
|
|
]
|
|
}
|
|
|
|
|
|
async def read_database_log_snapshot(
|
|
source_id: str,
|
|
*,
|
|
limit: int,
|
|
level: str,
|
|
levels: str | None,
|
|
start_date: str | None,
|
|
end_date: str | None,
|
|
search: str | None,
|
|
db: AsyncSession,
|
|
) -> dict | None:
|
|
selected_levels = set(normalize_log_level(item) for item in (levels or level).split(",") if item.strip())
|
|
selected_levels.discard("all")
|
|
search_query = (search or "").strip().lower()
|
|
lines: list[str] = []
|
|
|
|
if source_id == "system-db":
|
|
query = select(SystemLog).order_by(SystemLog.occurred_at.desc().nullslast(), SystemLog.id.desc()).limit(limit * 5)
|
|
result = await db.execute(query)
|
|
records = result.scalars().all()
|
|
for record in records:
|
|
record_level = normalize_log_level(record.level)
|
|
if selected_levels and record_level not in selected_levels:
|
|
continue
|
|
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
|
|
if start_date and occurred_at and occurred_at < start_date:
|
|
continue
|
|
if end_date and occurred_at and occurred_at > end_date:
|
|
continue
|
|
line = " ".join(
|
|
part
|
|
for part in [
|
|
record.occurred_at.isoformat() if record.occurred_at else "",
|
|
record_level.upper(),
|
|
record.source,
|
|
record.event or "",
|
|
record.message,
|
|
]
|
|
if part
|
|
)
|
|
if search_query and search_query not in line.lower():
|
|
continue
|
|
lines.append(line)
|
|
elif source_id == "audit-db":
|
|
query = select(AuditLog).order_by(AuditLog.occurred_at.desc().nullslast(), AuditLog.id.desc()).limit(limit * 5)
|
|
result = await db.execute(query)
|
|
records = result.scalars().all()
|
|
for record in records:
|
|
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
|
|
if start_date and occurred_at and occurred_at < start_date:
|
|
continue
|
|
if end_date and occurred_at and occurred_at > end_date:
|
|
continue
|
|
line = " ".join(
|
|
part
|
|
for part in [
|
|
record.occurred_at.isoformat() if record.occurred_at else "",
|
|
"INFO",
|
|
record.action,
|
|
record.target_type or "",
|
|
record.target_id or "",
|
|
record.result or "",
|
|
]
|
|
if part
|
|
)
|
|
if search_query and search_query not in line.lower():
|
|
continue
|
|
lines.append(line)
|
|
else:
|
|
return None
|
|
|
|
lines = list(reversed(lines[:limit]))
|
|
return {
|
|
"source_id": source_id,
|
|
"name": "系统事件" if source_id == "system-db" else "审计事件",
|
|
"kind": "database",
|
|
"location": "table://system_logs" if source_id == "system-db" else "table://audit_logs",
|
|
"description": "数据库持久化日志",
|
|
"category": "database" if source_id == "system-db" else "audit",
|
|
"status": "ok" if lines else "empty",
|
|
"level": level,
|
|
"selected_levels": sorted(selected_levels),
|
|
"search_query": search or "",
|
|
"available_levels": ["all", "error", "warning", "info", "debug"],
|
|
"daily_markers": [],
|
|
"line_limit": limit,
|
|
"line_count": len(lines),
|
|
"lines": lines,
|
|
}
|
|
|
|
|
|
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
|
async def get_system_log_snapshot(
|
|
source_id: str,
|
|
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
|
level: str = "all",
|
|
levels: str | None = Query(None, description="Comma-separated log levels"),
|
|
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
|
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
|
search: str | None = Query(None, description="Case-insensitive substring search"),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
ensure_super_admin(current_user)
|
|
|
|
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}",
|
|
)
|
|
if str(level).strip().lower() not in SUPPORTED_LOG_LEVELS and normalize_log_level(level) == "all" and str(level).strip().lower() not in {"", "all"}:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
|
if levels:
|
|
for raw_level in str(levels).split(","):
|
|
normalized_level = str(raw_level).strip().lower()
|
|
if not normalized_level:
|
|
continue
|
|
if normalized_level not in SUPPORTED_LOG_LEVELS and normalize_log_level(normalized_level) == "all":
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
|
normalized_start_date = validate_log_date(start_date, "start_date")
|
|
normalized_end_date = validate_log_date(end_date, "end_date")
|
|
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
|
|
|
snapshot = await read_database_log_snapshot(
|
|
source_id,
|
|
limit=limit,
|
|
level=level,
|
|
levels=levels,
|
|
start_date=normalized_start_date,
|
|
end_date=normalized_end_date,
|
|
search=search,
|
|
db=db,
|
|
)
|
|
if snapshot is None:
|
|
snapshot = read_log_snapshot(
|
|
source_id,
|
|
limit,
|
|
level=level,
|
|
levels=levels,
|
|
start_date=normalized_start_date,
|
|
end_date=normalized_end_date,
|
|
search=search,
|
|
)
|
|
if snapshot is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
|
return snapshot
|
|
|
|
|
|
@router.post("/logs/earth-client", response_model=EarthClientLogEventResponse)
|
|
async def ingest_earth_client_log(
|
|
payload: EarthClientLogEventCreate,
|
|
request: Request,
|
|
):
|
|
normalized_level = normalize_log_level(payload.level)
|
|
append_buffer_log(
|
|
"earth-client",
|
|
level=normalized_level,
|
|
message=payload.message,
|
|
context={
|
|
"category": payload.category or "",
|
|
"url": payload.url or "",
|
|
"module": payload.module or "",
|
|
"detail": payload.detail or "",
|
|
},
|
|
)
|
|
await record_system_log(
|
|
source="earth-client",
|
|
service="earth",
|
|
module=payload.module or "earth-client",
|
|
event="earth.client.runtime_log",
|
|
level=normalized_level,
|
|
message=payload.message,
|
|
category=payload.category or "client-runtime",
|
|
context={
|
|
"url": payload.url or "",
|
|
"detail": payload.detail or "",
|
|
"module": payload.module or "",
|
|
"client_ip": request.client.host if request.client else "",
|
|
},
|
|
)
|
|
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|