diff --git a/INIT.md b/INIT.md new file mode 100644 index 0000000..34ec84b --- /dev/null +++ b/INIT.md @@ -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 产物说明文案 + diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 2a8b9ed..62f4759 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -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) diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index a2f596e..0a334b7 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -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 diff --git a/backend/app/routers/tasks_runtime.py b/backend/app/routers/tasks_runtime.py new file mode 100644 index 0000000..353a8f3 --- /dev/null +++ b/backend/app/routers/tasks_runtime.py @@ -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, + } diff --git a/backend/app/services/job_handlers.py b/backend/app/services/job_handlers.py index 4832b58..d84e1f4 100644 --- a/backend/app/services/job_handlers.py +++ b/backend/app/services/job_handlers.py @@ -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__) diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 624441e..64fcadd 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -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() diff --git a/frontend/src/DinsarProductionPanel.jsx b/frontend/src/DinsarProductionPanel.jsx index 841725d..8f464d9 100644 --- a/frontend/src/DinsarProductionPanel.jsx +++ b/frontend/src/DinsarProductionPanel.jsx @@ -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 &&
{description}
} - {recommendation &&
建议:{recommendation}
} + {recommendation &&
推荐:{recommendation}
} ); } @@ -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 }} > - × + 关闭
         
{engines.length === 0 && !enginesLoading && ( - 暂无可用引擎信息。 + 暂无引擎信息。 )} {engines.map(engine => (
- 生产提交 + 提交生产任务
@@ -498,7 +542,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) setRootDir(event.target.value)} - placeholder="批量根目录或单个任务目录" + placeholder="批处理根目录或单个任务目录" disabled={readOnly} style={{ width: '100%', @@ -515,7 +559,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
- 这些参数主要影响目标网格尺寸、精轨裁剪、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格尺寸;只有在边缘被裁切、时间窗口异常或噪声较多时,再继续调整其他参数。 + 这些参数主要影响目标网格大小、精裁剪范围、地理编码范围和位移结果掩膜。建议先使用默认值,通常优先只调整目标网格大小;只有在边缘被裁切、时间窗异常或噪声较多时,再继续调整其他参数。
{Object.entries(currentParamSchema).map(([name, schema]) => ( @@ -600,7 +644,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) {submitting ? '提交中...' : '提交任务'} {submitMsg && ( - + {submitMsg} )} @@ -651,7 +695,26 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) )}
-
任务日志
+
+
任务日志
+ {!readOnly && ( + + )} +
{taskLogsLoading ? (
加载中...
) : taskLogs.length === 0 ? ( @@ -660,13 +723,38 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
{taskLogs.map((log, index) => (
-
- {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}] +
+
+ {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}] +
+
{log.message}
-
{log.message}
+ {!readOnly && ( + + )}
))}
@@ -675,7 +763,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued })
)} -
最近20条运行记录
+
最近 20 条运行记录
{runsLoading ? (
加载中...
) : runs.length === 0 ? ( @@ -684,7 +772,7 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) - {['任务编号', '引擎', '状态', '时间', '操作'].map(header => ( + {['运行ID', '引擎', '状态', '时间', '操作'].map(header => ( @@ -734,3 +822,4 @@ export default function DinsarProductionPanel({ readOnly = false, onJobQueued }) ); } + diff --git a/frontend/src/DinsarProductsPanel.jsx b/frontend/src/DinsarProductsPanel.jsx index 9f97b9d..1e6912d 100644 --- a/frontend/src/DinsarProductsPanel.jsx +++ b/frontend/src/DinsarProductsPanel.jsx @@ -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 (
- 产物提取与重扫 + D-InSAR 产物提取与重扫
- 这里负责把生产目录中的位移结果提取为标准结果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。 + 这里负责把生产目录中的位移结果提取为标准成果包,并触发结果重扫、发布和编目。生产运行与参数配置已独立放到“D-InSAR生产”选项卡。
setExtractRootDir(event.target.value)} - placeholder="结果根目录(提取位移结果)" + placeholder="结果根目录" style={{ flex: 2, minWidth: 220, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }} /> setExtractDestDir(event.target.value)} - placeholder="目标目录(留空使用默认)" + placeholder="目标目录(可选)" style={{ flex: 1, minWidth: 180, padding: '5px 8px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 13 }} />
{!activeTask ? ( -
当前没有运行中的产物处理任务。
+
当前没有正在执行的产物处理任务。
) : (
当前任务
@@ -269,7 +310,26 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) { )}
-
任务日志
+
+
任务日志
+ {!readOnly && ( + + )} +
{taskLogsLoading ? (
加载中...
) : taskLogs.length === 0 ? ( @@ -278,13 +338,38 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
{taskLogs.map((log, index) => (
-
- {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}] +
+
+ {(log.timestamp || '').replace('T', ' ').replace('Z', '')} [{log.level}] +
+
{log.message}
-
{log.message}
+ {!readOnly && ( + + )}
))}
@@ -302,3 +387,4 @@ export default function DinsarProductsPanel({ readOnly = false, onJobQueued }) {
); } + diff --git a/frontend/src/HealthCheckPanel.jsx b/frontend/src/HealthCheckPanel.jsx index b7b6927..05a10f1 100644 --- a/frontend/src/HealthCheckPanel.jsx +++ b/frontend/src/HealthCheckPanel.jsx @@ -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) => { diff --git a/frontend/src/LogManagementPanel.clean.jsx b/frontend/src/LogManagementPanel.clean.jsx new file mode 100644 index 0000000..5433242 --- /dev/null +++ b/frontend/src/LogManagementPanel.clean.jsx @@ -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 ( +
+
+

日志管理

+
+ + + +
+
+ + {logs.length === 0 ? ( +
暂无日志文件
+ ) : ( +
- 日志 + 查看日志
+ + + + + + + + + + + {logs.map((log, index) => ( + + + + + + + + ))} + +
文件名类型大小修改时间操作
{log.name} + + {getTypeLabel(log.type)} + + + {formatSize(log.size)} + {log.modified_at} +
+ + {isAdmin && ( + + )} +
+
+ )} + + {showModal && selectedLog && ( +
+
+
+
+

{selectedLog.name}

+
+ 大小:{formatSize(selectedLog.size)} | 修改时间:{selectedLog.modified_at} | 总行数:{totalLines} +
+
+ +
+ +
+ setSearchTerm(event.target.value)} + style={{ + flex: 1, + padding: '6px 12px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '13px', + }} + /> +
+ 显示行 {totalLines === 0 ? 0 : currentOffset + 1} - {Math.min(currentOffset + PAGE_SIZE, totalLines)} +
+ + +
+ +
+
+                {filteredContent || '(空日志)'}
+              
+
+
+
+ )} +
+ ); +}; + +export default LogManagementPanel; diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js index 08f7abc..3bdedc5 100644 --- a/frontend/src/api/tasks.js +++ b/frontend/src/api/tasks.js @@ -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);