Fix D-InSAR runtime logs and UI encoding

This commit is contained in:
2026-04-16 22:37:09 +08:00
parent 0a8f3331e3
commit a666ed0e5c
11 changed files with 949 additions and 199 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ from . import (
root_registry,
stats,
task_batches,
tasks,
tasks_runtime,
timeseries_production,
tools,
unpack,
@@ -36,7 +36,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(health.router)
router.include_router(auth.router)
router.include_router(license.router)
router.include_router(tasks.router)
router.include_router(tasks_runtime.router)
router.include_router(workflow.router)
router.include_router(task_batches.router)
router.include_router(tools.router)
+1 -150
View File
@@ -1,150 +1 @@
from __future__ import annotations
import asyncio
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import database
from ..auth_utils import verify_password
from ..models import AuthUserORM, TaskInfo
from ..services.dinsar_production_service import dinsar_production_service
from ..services.task_service import (
TASK_ACTIVE_DEFAULT_LIMIT,
TASK_ACTIVE_MAX_LIMIT,
TASK_LOG_DEFAULT_LIMIT,
TASK_LOG_MAX_LIMIT,
TASK_QUERY_MAX_OFFSET,
task_service,
)
from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
from .dependencies import _require_admin
router = APIRouter()
class ForceCancelRequest(BaseModel):
password: str
def _new_session():
if database.AsyncSessionLocal is None:
database.init_db()
if database.AsyncSessionLocal is None:
raise RuntimeError("Database session factory is not initialized.")
return database.AsyncSessionLocal()
@router.get("/tasks/active", response_model=List[TaskInfo])
async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
"""获取当前正在运行的所有后台任务"""
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
orm_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset)
return [TaskInfo.model_validate(t) for t in orm_tasks]
@router.get("/tasks/active/stream")
async def stream_active_tasks(request: Request):
"""SSE 端点:每 3s 推送一次活跃任务列表,替代前端轮询。"""
# Authenticate via Cookie at connection establishment
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
async with _new_session() as db:
user = await get_user_by_session_token(db, token)
if not user:
raise HTTPException(status_code=401, detail="Authentication required.")
else:
raise HTTPException(status_code=401, detail="Authentication required.")
async def event_generator():
while True:
if await request.is_disconnected():
break
try:
orm_tasks = await task_service.get_active_tasks(
limit=TASK_ACTIVE_MAX_LIMIT, offset=0
)
tasks_data = [TaskInfo.model_validate(t).model_dump() for t in orm_tasks]
yield f"data: {json.dumps(tasks_data)}\n\n"
except Exception:
yield "data: []\n\n"
await asyncio.sleep(3)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.get("/tasks/{task_id}", response_model=Optional[TaskInfo])
async def get_task_status(task_id: str):
"""获取特定任务的状态"""
task = await task_service.get_task(task_id)
if task:
return TaskInfo.model_validate(task)
return None
@router.get("/tasks/{task_id}/logs")
async def get_task_logs(task_id: str, limit: int = TASK_LOG_DEFAULT_LIMIT, offset: int = 0):
"""获取指定任务的日志(支持 limit/offset)。"""
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务未找到")
safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
return {
"task_id": task_id,
"limit": safe_limit,
"offset": safe_offset,
"count": len(logs),
"logs": [
{
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"level": log.log_level,
"message": log.message,
}
for log in logs
],
}
@router.post("/tasks/{task_id}/force-cancel")
async def force_cancel_task(
task_id: str,
body: ForceCancelRequest,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""管理员输入密码后强制取消任务(解锁前端)"""
if not verify_password(body.password, admin_user.password_hash):
raise HTTPException(status_code=403, detail="密码错误")
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务未找到")
if task.status not in ("PENDING", "RUNNING"):
raise HTTPException(status_code=400, detail="任务已结束,无需取消")
killed_pid = None
async with _new_session() as db:
run = await dinsar_production_service.request_cancel(task_id, db=db)
if run is not None:
killed_pid = await dinsar_production_service.kill_active_execution_by_task_id(
task_id,
db=db,
)
await task_service.update_task(task_id, status="CANCELLED", message="管理员强制取消")
return {
"message": "任务已强制取消",
"task_id": task_id,
"killed_pid": killed_pid,
}
from .tasks_runtime import * # noqa: F401,F403
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import asyncio
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import database
from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
from ..auth_utils import verify_password
from ..models import AuthUserORM, TaskInfo
from ..services.dinsar_production_service import dinsar_production_service
from ..services.task_service import (
TASK_ACTIVE_DEFAULT_LIMIT,
TASK_ACTIVE_MAX_LIMIT,
TASK_LOG_DEFAULT_LIMIT,
TASK_LOG_MAX_LIMIT,
TASK_QUERY_MAX_OFFSET,
task_service,
)
from .dependencies import _require_admin
router = APIRouter()
class ForceCancelRequest(BaseModel):
password: str
def _new_session():
if database.AsyncSessionLocal is None:
database.init_db()
if database.AsyncSessionLocal is None:
raise RuntimeError("Database session factory is not initialized.")
return database.AsyncSessionLocal()
@router.get("/tasks/active", response_model=List[TaskInfo])
async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
orm_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset)
return [TaskInfo.model_validate(task) for task in orm_tasks]
@router.get("/tasks/active/stream")
async def stream_active_tasks(request: Request):
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Authentication required.")
async with _new_session() as db:
user = await get_user_by_session_token(db, token)
if not user:
raise HTTPException(status_code=401, detail="Authentication required.")
async def event_generator():
while True:
if await request.is_disconnected():
break
try:
orm_tasks = await task_service.get_active_tasks(
limit=TASK_ACTIVE_MAX_LIMIT,
offset=0,
)
tasks_data = [TaskInfo.model_validate(task).model_dump() for task in orm_tasks]
yield f"data: {json.dumps(tasks_data)}\n\n"
except Exception:
yield "data: []\n\n"
await asyncio.sleep(3)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.get("/tasks/{task_id}", response_model=Optional[TaskInfo])
async def get_task_status(task_id: str):
task = await task_service.get_task(task_id)
if task:
return TaskInfo.model_validate(task)
return None
@router.get("/tasks/{task_id}/logs")
async def get_task_logs(task_id: str, limit: int = TASK_LOG_DEFAULT_LIMIT, offset: int = 0):
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found.")
safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
return {
"task_id": task_id,
"limit": safe_limit,
"offset": safe_offset,
"count": len(logs),
"logs": [
{
"id": log.id,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"level": log.log_level,
"message": log.message,
}
for log in logs
],
}
@router.delete("/tasks/{task_id}/logs/{log_id}")
async def delete_task_log(
task_id: str,
log_id: int,
admin_user: AuthUserORM = Depends(_require_admin),
):
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found.")
deleted = await task_service.delete_log(task_id, log_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task log entry not found.")
return {
"task_id": task_id,
"log_id": log_id,
"deleted": True,
}
@router.delete("/tasks/{task_id}/logs")
async def clear_task_logs(
task_id: str,
admin_user: AuthUserORM = Depends(_require_admin),
):
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found.")
deleted_count = await task_service.clear_logs(task_id)
return {
"task_id": task_id,
"deleted_count": deleted_count,
}
@router.post("/tasks/{task_id}/force-cancel")
async def force_cancel_task(
task_id: str,
body: ForceCancelRequest,
admin_user: AuthUserORM = Depends(_require_admin),
):
if not verify_password(body.password, admin_user.password_hash):
raise HTTPException(status_code=403, detail="Password is incorrect.")
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found.")
if task.status not in ("PENDING", "RUNNING"):
raise HTTPException(status_code=400, detail="Task is already finished.")
killed_pid = None
async with _new_session() as db:
run = await dinsar_production_service.request_cancel(task_id, db=db)
if run is not None:
killed_pid = await dinsar_production_service.kill_active_execution_by_task_id(
task_id,
db=db,
)
await task_service.update_task(
task_id,
status="CANCELLED",
message="Task cancelled by administrator.",
)
return {
"message": "Task cancelled.",
"task_id": task_id,
"killed_pid": killed_pid,
}
+1
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import time
import uuid
from datetime import datetime
from typing import Callable, Awaitable, Optional, Dict, Any, List
logger = logging.getLogger(__name__)
+54 -1
View File
@@ -8,7 +8,7 @@ import sys
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import func, and_, text
from sqlalchemy import delete, func, and_, text
from ..config import read_int_env
from ..models import SystemTaskORM, TaskLogORM
@@ -367,4 +367,57 @@ class TaskService:
# 创建全局单例实例
async def delete_log(
self,
task_id: str,
log_id: int,
db: Optional[AsyncSession] = None,
) -> bool:
gen_db = db is None
if gen_db:
db = get_db_session()
try:
result = await db.execute(
select(TaskLogORM).where(
TaskLogORM.id == int(log_id),
TaskLogORM.task_id == task_id,
)
)
log_entry = result.scalar_one_or_none()
if log_entry is None:
return False
await db.delete(log_entry)
await db.commit()
return True
except Exception:
await db.rollback()
raise
finally:
if gen_db:
await db.close()
async def clear_logs(
self,
task_id: str,
db: Optional[AsyncSession] = None,
) -> int:
gen_db = db is None
if gen_db:
db = get_db_session()
try:
result = await db.execute(
delete(TaskLogORM).where(TaskLogORM.task_id == task_id)
)
await db.commit()
return int(result.rowcount or 0)
except Exception:
await db.rollback()
raise
finally:
if gen_db:
await db.close()
task_service = TaskService()