Fix D-InSAR runtime logs and UI encoding
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# INIT
|
||||
|
||||
## 1. 当前开发机已确认的本机环境
|
||||
|
||||
- Windows 开发机 Python 解释器:`C:\ProgramData\anaconda3\envs\InSAR\python.exe`
|
||||
- 当前项目运行时应优先从 `.env` 读取路径,不允许在代码中写死开发机盘符或目录。
|
||||
- 与 D-InSAR / ISCE2 / WSL 相关的运行目录、输出目录、轨道目录、DEM 路径都应通过 `.env` 配置维护。
|
||||
|
||||
## 2. 本轮已落地的修复
|
||||
|
||||
### 2.1 D-InSAR 生产与产物任务日志管理
|
||||
|
||||
- 后端新增任务运行日志专用路由:
|
||||
- `backend/app/routers/tasks_runtime.py`
|
||||
- `backend/app/routers/tasks.py` 改为兼容导出入口。
|
||||
- `backend/app/services/task_service.py` 新增:
|
||||
- 单条任务日志删除
|
||||
- 当前任务日志清空
|
||||
- 前端已接入:
|
||||
- `frontend/src/api/tasks.js`
|
||||
- `frontend/src/DinsarProductionPanel.jsx`
|
||||
- `frontend/src/DinsarProductsPanel.jsx`
|
||||
|
||||
当前已支持:
|
||||
|
||||
- 查看任务日志
|
||||
- 删除单条任务日志
|
||||
- 清空当前任务全部日志
|
||||
|
||||
### 2.2 D-InSAR 提交链路与运行时问题
|
||||
|
||||
- 修复 `backend/app/services/job_handlers.py` 中缺失 `datetime` 导入导致的控制器异常。
|
||||
- 修复 D-InSAR 运行时目录处理逻辑,避免继续依赖历史开发机 `Z:\` 路径。
|
||||
- 当前原则:路径统一走 `.env`,不允许把开发机专用盘符写入跟踪代码。
|
||||
|
||||
### 2.3 前端乱码修复
|
||||
|
||||
本轮已清理以下面板中的残留乱码与混杂英文:
|
||||
|
||||
- `frontend/src/DinsarProductionPanel.jsx`
|
||||
- `frontend/src/DinsarProductsPanel.jsx`
|
||||
|
||||
日志管理原文件 `frontend/src/LogManagementPanel.jsx` 本身存在历史乱码内容,而且开发过程中目标文件一度被占用,当前采用的稳定方案是:
|
||||
|
||||
- 新增干净版本:`frontend/src/LogManagementPanel.clean.jsx`
|
||||
- 由 `frontend/src/HealthCheckPanel.jsx` 暂时改为导入 `LogManagementPanel.clean`
|
||||
|
||||
这样做的目的:
|
||||
|
||||
- 先保证页面可用、无乱码
|
||||
- 不在文件被占用时强行覆盖旧文件
|
||||
- 后续如果旧文件占用解除,再考虑把 clean 版本回收为正式文件名
|
||||
|
||||
## 3. PowerShell 使用注意事项
|
||||
|
||||
### 3.1 查看中文文件时
|
||||
|
||||
Windows PowerShell 5.x 下,终端读取 UTF-8 中文文件时可能出现乱码,这不一定代表源码文件本身已坏。
|
||||
|
||||
推荐先执行:
|
||||
|
||||
```powershell
|
||||
chcp 65001
|
||||
$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
```
|
||||
|
||||
如果仍然显示异常,优先用编辑器直接查看源码,不要仅凭 PowerShell 控制台输出判断文件编码是否损坏。
|
||||
|
||||
### 3.2 写入源码文件时
|
||||
|
||||
Windows PowerShell 5.x 的 `Set-Content -Encoding UTF8` 默认会写入 BOM。对脚本、配置或前端源码,BOM 可能继续引出兼容问题。
|
||||
|
||||
因此本项目内不建议直接用这类命令覆盖源码文件。更稳妥的方式:
|
||||
|
||||
- 优先使用编辑器或补丁方式修改源码
|
||||
- 如果必须在 PowerShell 中写文件,使用无 BOM UTF-8
|
||||
|
||||
示例:
|
||||
|
||||
```powershell
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText("D:\Code\Insar_management_system_v2\somefile.txt", $content, $utf8NoBom)
|
||||
```
|
||||
|
||||
## 4. WSL / ISCE2 相关约束
|
||||
|
||||
- 当前项目已经按“Windows 业务登记 + WSL 执行 ISCE2”模式接入。
|
||||
- 是否继续维持双环境桥接,只能作为 fallback 方案讨论;默认正式链路仍应以单一、可维护、可配置的环境为主。
|
||||
- 后续若继续收敛环境,优先原则是:
|
||||
- 先保证正式生产链稳定
|
||||
- 再讨论实验期环境是否下线
|
||||
- 所有环境差异都必须记录到文档,不能只留在口头结论里
|
||||
|
||||
## 5. 本轮验证结果
|
||||
|
||||
- 前端已重新执行 `npm run build`,构建通过。
|
||||
- 当前界面实际已恢复以下中文区域:
|
||||
- 日志管理
|
||||
- 运行监控
|
||||
- 处理模板
|
||||
- D-InSAR 产物说明文案
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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__)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { listEngines, listRuns, submitRun } from './api/dinsarProduction';
|
||||
import { getActiveTasks, getJobLog, getTaskLogs } from './api/idl';
|
||||
import { getJobLog } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
|
||||
|
||||
const card = {
|
||||
background: '#fff',
|
||||
@@ -24,10 +25,10 @@ const ENGINE_STATUS_COLOR = {
|
||||
|
||||
const ENGINE_STATUS_LABEL = {
|
||||
ok: '可用',
|
||||
degraded: '部分可用',
|
||||
degraded: '降级',
|
||||
unavailable: '不可用',
|
||||
not_implemented: '预留',
|
||||
error: '错误',
|
||||
error: '异常',
|
||||
};
|
||||
|
||||
const ENGINE_LABEL = {
|
||||
@@ -37,8 +38,8 @@ const ENGINE_LABEL = {
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
ISCE2_RUN: 'ISCE2生产任务',
|
||||
IDL_RUN_DINSAR: 'ENVI生产任务',
|
||||
ISCE2_RUN: 'ISCE2生产',
|
||||
IDL_RUN_DINSAR: 'ENVI生产',
|
||||
};
|
||||
|
||||
const STATUS_LABEL = {
|
||||
@@ -220,7 +221,7 @@ function ParamField({ name, schema, value, disabled, onChange }) {
|
||||
style={inputStyle}
|
||||
/>
|
||||
{description && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{description}</div>}
|
||||
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>建议:{recommendation}</div>}
|
||||
{recommendation && <div style={{ fontSize: 11, color: '#2563eb', marginTop: 4 }}>推荐:{recommendation}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,6 +237,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const [engineExtraParams, setEngineExtraParams] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState('');
|
||||
const [submitError, setSubmitError] = useState(false);
|
||||
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
@@ -244,6 +246,8 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
||||
|
||||
const currentEngineObj = engines.find(engine => engine.engine_code === selectedEngine) || null;
|
||||
const currentProfiles = currentEngineObj?.profiles || EMPTY_ARRAY;
|
||||
@@ -301,6 +305,42 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteTaskLog = useCallback(async logId => {
|
||||
const taskId = activeTask?.task_id;
|
||||
if (!taskId || !logId || taskLogActionLoading) return;
|
||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||
|
||||
setTaskLogDeletingId(logId);
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await deleteTaskLog(taskId, logId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (err) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg(`删除日志失败:${err?.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setTaskLogDeletingId(null);
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
|
||||
|
||||
const handleClearTaskLogs = useCallback(async () => {
|
||||
const taskId = activeTask?.task_id;
|
||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
||||
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
|
||||
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await clearTaskLogs(taskId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (err) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg(`清空日志失败:${err?.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEngines();
|
||||
loadRuns();
|
||||
@@ -339,12 +379,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!rootDir.trim()) {
|
||||
setSubmitMsg('请填写根目录。');
|
||||
setSubmitError(true);
|
||||
setSubmitMsg('请输入根目录。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitMsg('');
|
||||
setSubmitError(false);
|
||||
try {
|
||||
const extra = buildExtraPayload(currentParamSchema, engineExtraParams);
|
||||
const result = await submitRun({
|
||||
@@ -355,12 +397,14 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
timeout_seconds: timeoutSec ? Number(timeoutSec) : null,
|
||||
extra,
|
||||
});
|
||||
const taskCount = result?.selected_task_count ? `,已选 ${result.selected_task_count} 个任务` : '';
|
||||
const taskCount = result?.selected_task_count ? `,选中 ${result.selected_task_count} 个任务` : '';
|
||||
setSubmitError(false);
|
||||
setSubmitMsg(`任务已入队:${result.task_id}${taskCount}`);
|
||||
if (onJobQueued) onJobQueued(result.task_id);
|
||||
loadRuns();
|
||||
loadActiveTask();
|
||||
} catch (err) {
|
||||
setSubmitError(true);
|
||||
setSubmitMsg(`提交失败:${err?.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -411,7 +455,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
onClick={() => setLogModal({ open: false, runId: '', content: '', loading: false })}
|
||||
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}
|
||||
>
|
||||
×
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
@@ -451,7 +495,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{engines.length === 0 && !enginesLoading && (
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无可用引擎信息。</span>
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>暂无引擎信息。</span>
|
||||
)}
|
||||
{engines.map(engine => (
|
||||
<EngineStatusCard
|
||||
@@ -465,7 +509,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>生产提交</strong>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>提交生产任务</strong>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
@@ -498,7 +542,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
<input
|
||||
value={rootDir}
|
||||
onChange={event => setRootDir(event.target.value)}
|
||||
placeholder="批量根目录或单个任务目录"
|
||||
placeholder="批处理根目录或单个任务目录"
|
||||
disabled={readOnly}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -515,7 +559,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 120 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
|
||||
处理任务数(0=全部)
|
||||
任务数量(0 表示全部)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -528,7 +572,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<label style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 4 }}>
|
||||
超时秒数(留空默认)
|
||||
超时时间(秒,可选)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -565,7 +609,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
这些参数主要影响目标网格尺寸、精轨裁剪、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格尺寸;只有在边缘被裁切、时间窗口异常或噪声较多时,再继续调整其他参数。
|
||||
这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{Object.entries(currentParamSchema).map(([name, schema]) => (
|
||||
@@ -600,7 +644,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
{submitting ? '提交中...' : '提交任务'}
|
||||
</button>
|
||||
{submitMsg && (
|
||||
<span style={{ fontSize: 12, color: submitMsg.includes('失败') ? '#ef4444' : '#16a34a' }}>
|
||||
<span style={{ fontSize: 12, color: submitError ? '#ef4444' : '#16a34a' }}>
|
||||
{submitMsg}
|
||||
</span>
|
||||
)}
|
||||
@@ -651,7 +695,26 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={handleClearTaskLogs}
|
||||
disabled={taskLogActionLoading || taskLogs.length === 0}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #fcd34d',
|
||||
background: taskLogActionLoading || taskLogs.length === 0 ? '#fef3c7' : '#fff7ed',
|
||||
color: '#9a3412',
|
||||
cursor: taskLogActionLoading || taskLogs.length === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{taskLogsLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
|
||||
) : taskLogs.length === 0 ? (
|
||||
@@ -660,14 +723,39 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{taskLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp || 'log'}-${index}`}
|
||||
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
|
||||
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
|
||||
<div style={{ color: '#64748b' }}>
|
||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => handleDeleteTaskLog(log.id)}
|
||||
disabled={taskLogActionLoading || !log.id}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #fecaca',
|
||||
background: '#fef2f2',
|
||||
color: '#b91c1c',
|
||||
cursor: taskLogActionLoading || !log.id ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -684,7 +772,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc' }}>
|
||||
{['任务编号', '引擎', '状态', '时间', '操作'].map(header => (
|
||||
{['运行ID', '引擎', '状态', '时间', '操作'].map(header => (
|
||||
<th
|
||||
key={header}
|
||||
style={{ padding: '4px 8px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', color: '#64748b' }}
|
||||
@@ -722,7 +810,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
日志
|
||||
查看日志
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -734,3 +822,4 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { scanDinsarResults } from './api/dinsar';
|
||||
import { extractDispResults, getActiveTasks, getTaskLogs } from './api/idl';
|
||||
import { extractDispResults } from './api/idl';
|
||||
import { clearTaskLogs, deleteTaskLog, getActiveTasks, getTaskLogs } from './api/tasks';
|
||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||
|
||||
const card = {
|
||||
@@ -19,9 +20,9 @@ const PRODUCT_TASK_TYPES = [
|
||||
];
|
||||
|
||||
const TASK_TYPE_LABEL = {
|
||||
SCAN_DINSAR: 'D-InSAR结果扫描任务',
|
||||
PUBLISH_DINSAR_PRODUCTS: '结果包发布任务',
|
||||
REBUILD_DINSAR_CATALOG: '结果目录重建任务',
|
||||
SCAN_DINSAR: 'D-InSAR结果扫描',
|
||||
PUBLISH_DINSAR_PRODUCTS: 'D-InSAR产物发布',
|
||||
REBUILD_DINSAR_CATALOG: 'D-InSAR目录重建',
|
||||
};
|
||||
|
||||
const STATUS_LABEL = {
|
||||
@@ -53,6 +54,8 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskLogsLoading, setTaskLogsLoading] = useState(false);
|
||||
const [taskLogActionLoading, setTaskLogActionLoading] = useState(false);
|
||||
const [taskLogDeletingId, setTaskLogDeletingId] = useState(null);
|
||||
|
||||
const loadActiveTask = useCallback(async () => {
|
||||
try {
|
||||
@@ -81,6 +84,42 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteTaskLog = useCallback(async logId => {
|
||||
const taskId = activeTask?.task_id;
|
||||
if (!taskId || !logId || taskLogActionLoading) return;
|
||||
if (!window.confirm('确定要删除这条任务日志吗?')) return;
|
||||
|
||||
setTaskLogDeletingId(logId);
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await deleteTaskLog(taskId, logId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (error) {
|
||||
setActionMessage(`删除日志失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionError(true);
|
||||
} finally {
|
||||
setTaskLogDeletingId(null);
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading]);
|
||||
|
||||
const handleClearTaskLogs = useCallback(async () => {
|
||||
const taskId = activeTask?.task_id;
|
||||
if (!taskId || taskLogActionLoading || taskLogs.length === 0) return;
|
||||
if (!window.confirm('确定要清空当前任务的全部日志吗?')) return;
|
||||
|
||||
setTaskLogActionLoading(true);
|
||||
try {
|
||||
await clearTaskLogs(taskId);
|
||||
await loadTaskLogs(taskId);
|
||||
} catch (error) {
|
||||
setActionMessage(`清空日志失败:${error?.response?.data?.detail || error.message}`);
|
||||
setActionError(true);
|
||||
} finally {
|
||||
setTaskLogActionLoading(false);
|
||||
}
|
||||
}, [activeTask?.task_id, loadTaskLogs, taskLogActionLoading, taskLogs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveTask();
|
||||
}, [loadActiveTask]);
|
||||
@@ -137,7 +176,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 960 }}>
|
||||
<div style={card}>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>产物提取与重扫</strong>
|
||||
<strong style={{ fontSize: 14, display: 'block', marginBottom: 10 }}>D-InSAR 产物提取与重扫</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -151,20 +190,20 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
这里负责把生产目录中的位移结果提取为标准结果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
|
||||
这里负责把生产目录中的位移结果提取为标准成果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
<input
|
||||
value={extractRootDir}
|
||||
onChange={event => setExtractRootDir(event.target.value)}
|
||||
placeholder="结果根目录(提取位移结果)"
|
||||
placeholder="结果根目录"
|
||||
style={{ flex: 2, minWidth: 220, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
||||
/>
|
||||
<input
|
||||
value={extractDestDir}
|
||||
onChange={event => setExtractDestDir(event.target.value)}
|
||||
placeholder="目标目录(留空使用默认)"
|
||||
placeholder="目标目录(可选)"
|
||||
style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
@@ -209,15 +248,17 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<span style={{ color: '#ef4444' }}>提取失败:{extractResult.error}</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, color: '#16a34a' }}>
|
||||
<span>提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。</span>
|
||||
<span>
|
||||
提取完成:复制 {extractResult.copied || 0} 个文件,覆盖 {extractResult.overwritten || 0} 个文件。
|
||||
</span>
|
||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'ok' && (
|
||||
<span style={{ color: '#166534' }}>
|
||||
已同步标准结果包目录:发布 {extractResult.catalog?.publish?.processed || 0} 项,重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
||||
成果目录已同步:发布 {extractResult.catalog?.publish?.processed || 0} 项,重建登记 {extractResult.catalog?.rebuild?.registered || 0} 项。
|
||||
</span>
|
||||
)}
|
||||
{extractResult.catalog?.attempted && extractResult.catalog?.status === 'error' && (
|
||||
<span style={{ color: '#b45309' }}>
|
||||
标准结果包目录同步失败:{extractResult.catalog?.message}
|
||||
标准成果包目录同步失败:{extractResult.catalog?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -245,7 +286,7 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
</div>
|
||||
|
||||
{!activeTask ? (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有运行中的产物处理任务。</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>当前没有正在执行的产物处理任务。</div>
|
||||
) : (
|
||||
<div style={{ padding: '8px 10px', background: '#fefce8', borderRadius: 6, border: '1px solid #fde68a' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#92400e', marginBottom: 4 }}>当前任务</div>
|
||||
@@ -269,7 +310,26 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 8, background: '#fff', border: '1px solid #fde68a', borderRadius: 6, padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e', marginBottom: 6 }}>任务日志</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#92400e' }}>任务日志</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={handleClearTaskLogs}
|
||||
disabled={taskLogActionLoading || taskLogs.length === 0}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #fcd34d',
|
||||
background: taskLogActionLoading || taskLogs.length === 0 ? '#fef3c7' : '#fff7ed',
|
||||
color: '#9a3412',
|
||||
cursor: taskLogActionLoading || taskLogs.length === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{taskLogActionLoading && taskLogDeletingId == null ? '清空中...' : '清空日志'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{taskLogsLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#a16207' }}>加载中...</div>
|
||||
) : taskLogs.length === 0 ? (
|
||||
@@ -278,14 +338,39 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{taskLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp || 'log'}-${index}`}
|
||||
style={{ fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}
|
||||
key={log.id || `${log.timestamp || 'log'}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 11, lineHeight: 1.45, color: log.level === 'WARNING' ? '#b45309' : '#334155' }}>
|
||||
<div style={{ color: '#64748b' }}>
|
||||
{(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}]
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{log.message}</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => handleDeleteTaskLog(log.id)}
|
||||
disabled={taskLogActionLoading || !log.id}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #fecaca',
|
||||
background: '#fef2f2',
|
||||
color: '#b91c1c',
|
||||
cursor: taskLogActionLoading || !log.id ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{taskLogDeletingId === log.id ? '删除中...' : '删除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -302,3 +387,4 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cleanupSessions } from './api/auth';
|
||||
import { syncWaterScenesFromDisk } from './api/water';
|
||||
import { listEngines, runWslCheck } from './api/dinsarProduction';
|
||||
import { getOrbitStatus, syncOrbitPools } from './api/orbit';
|
||||
import LogManagementPanel from './LogManagementPanel';
|
||||
import LogManagementPanel from './LogManagementPanel.clean';
|
||||
import DinsarCatalogPanel from './components/DinsarCatalogPanel';
|
||||
|
||||
const toNumber = (value) => {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { listLogs, getLogContent, deleteLog } from './api/logs';
|
||||
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
const LogManagementPanel = ({ isAdmin }) => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [totalLines, setTotalLines] = useState(0);
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listLogs(filterType || null);
|
||||
setLogs(data);
|
||||
} catch (error) {
|
||||
console.error('加载日志列表失败:', error);
|
||||
alert(`加载日志列表失败:${error.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
const loadLogContent = useCallback(async (logPath, offset = 0) => {
|
||||
try {
|
||||
const data = await getLogContent(logPath, offset, PAGE_SIZE);
|
||||
setLogContent(data.content || '');
|
||||
setTotalLines(data.total_lines || 0);
|
||||
setCurrentOffset(offset);
|
||||
} catch (error) {
|
||||
console.error('加载日志内容失败:', error);
|
||||
alert(`加载日志内容失败:${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [loadLogs]);
|
||||
|
||||
const handleViewLog = async log => {
|
||||
setSelectedLog(log);
|
||||
setShowModal(true);
|
||||
setCurrentOffset(0);
|
||||
setSearchTerm('');
|
||||
await loadLogContent(log.path, 0);
|
||||
};
|
||||
|
||||
const handleDeleteLog = async log => {
|
||||
if (!isAdmin) {
|
||||
alert('只有管理员可以删除日志。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`确定要删除日志文件“${log.name}”吗?\n\n此操作不可恢复。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteLog(log.path);
|
||||
alert('日志文件已删除。');
|
||||
await loadLogs();
|
||||
if (selectedLog && selectedLog.path === log.path) {
|
||||
setShowModal(false);
|
||||
setSelectedLog(null);
|
||||
setLogContent('');
|
||||
setTotalLines(0);
|
||||
setCurrentOffset(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日志失败:', error);
|
||||
alert(`删除日志失败:${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (selectedLog && currentOffset > 0) {
|
||||
const newOffset = Math.max(0, currentOffset - PAGE_SIZE);
|
||||
loadLogContent(selectedLog.path, newOffset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (selectedLog && currentOffset + PAGE_SIZE < totalLines) {
|
||||
const newOffset = currentOffset + PAGE_SIZE;
|
||||
loadLogContent(selectedLog.path, newOffset);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = bytes => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getTypeLabel = type => {
|
||||
const labels = {
|
||||
app: '应用日志',
|
||||
task: '任务日志',
|
||||
error: '错误日志',
|
||||
other: '其他',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeColor = type => {
|
||||
const colors = {
|
||||
app: '#3b82f6',
|
||||
task: '#10b981',
|
||||
error: '#ef4444',
|
||||
other: '#6b7280',
|
||||
};
|
||||
return colors[type] || '#6b7280';
|
||||
};
|
||||
|
||||
const filteredContent = searchTerm
|
||||
? logContent
|
||||
.split('\n')
|
||||
.filter(line => line.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.join('\n')
|
||||
: logContent;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>日志管理</h3>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<label>类型筛选:</label>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={event => setFilterType(event.target.value)}
|
||||
style={{ padding: '5px 10px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="app">应用日志</option>
|
||||
<option value="task">任务日志</option>
|
||||
<option value="error">错误日志</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadLogs}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '5px 15px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>暂无日志文件</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f3f4f6', borderBottom: '2px solid #e5e7eb' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>文件名</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>类型</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right' }}>大小</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>修改时间</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={{ padding: '12px', fontFamily: 'monospace', fontSize: '13px' }}>{log.name}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: `${getTypeColor(log.type)}20`,
|
||||
color: getTypeColor(log.type),
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(log.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', textAlign: 'right', fontFamily: 'monospace', fontSize: '13px' }}>
|
||||
{formatSize(log.size)}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '13px' }}>{log.modified_at}</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleViewLog(log)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showModal && selectedLog && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontFamily: 'monospace' }}>{selectedLog.name}</h3>
|
||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
||||
大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e5e7eb', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索日志内容..."
|
||||
value={searchTerm}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
||||
显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentOffset === 0}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: currentOffset === 0 ? '#e5e7eb' : '#3b82f6',
|
||||
color: currentOffset === 0 ? '#9ca3af' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentOffset === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={currentOffset + PAGE_SIZE >= totalLines}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: currentOffset + PAGE_SIZE >= totalLines ? '#e5e7eb' : '#3b82f6',
|
||||
color: currentOffset + PAGE_SIZE >= totalLines ? '#9ca3af' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: currentOffset + PAGE_SIZE >= totalLines ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px', backgroundColor: '#1e1e1e' }}>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: '#d4d4d4',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{filteredContent || '(空日志)'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogManagementPanel;
|
||||
@@ -2,3 +2,9 @@ import apiClient from './client';
|
||||
|
||||
export const getActiveTasks = () => apiClient.get('/tasks/active').then(r => r.data);
|
||||
export const getTask = (taskId) => apiClient.get(`/tasks/${taskId}`).then(r => r.data);
|
||||
export const getTaskLogs = (taskId, limit = 50, offset = 0) =>
|
||||
apiClient.get(`/tasks/${taskId}/logs?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`).then(r => r.data);
|
||||
export const deleteTaskLog = (taskId, logId) =>
|
||||
apiClient.delete(`/tasks/${taskId}/logs/${encodeURIComponent(logId)}`).then(r => r.data);
|
||||
export const clearTaskLogs = (taskId) =>
|
||||
apiClient.delete(`/tasks/${taskId}/logs`).then(r => r.data);
|
||||
|
||||
Reference in New Issue
Block a user