Add ops task maintenance cleanup panel

This commit is contained in:
2026-07-02 11:14:02 +08:00
parent 8ea3084b14
commit 9c5cbc4637
7 changed files with 1464 additions and 0 deletions
+2
View File
@@ -18,6 +18,7 @@ from . import (
license, license,
logs, logs,
monitor, monitor,
ops_maintenance,
orbit, orbit,
pairing, pairing,
ps_products, ps_products,
@@ -48,6 +49,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(tools.router) router.include_router(tools.router)
router.include_router(unpack.router) router.include_router(unpack.router)
router.include_router(monitor.router) router.include_router(monitor.router)
router.include_router(ops_maintenance.router)
router.include_router(orbit.router) router.include_router(orbit.router)
router.include_router(assets.router) router.include_router(assets.router)
router.include_router(root_registry.router) router.include_router(root_registry.router)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from ..services.ops_maintenance_service import ops_maintenance_service
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel):
confirm: bool = False
delete_task_records: bool = True
delete_logs: bool = True
delete_production_records: bool = True
delete_result_products: bool = True
delete_production_dirs: bool = True
delete_task_pool_dir: bool = True
@router.get("/tasks")
async def list_maintenance_tasks(
task_type: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
return await ops_maintenance_service.list_tasks(
db,
task_type=task_type,
status=status,
limit=limit,
offset=offset,
)
@router.get("/tasks/{task_id}/diagnosis")
async def diagnose_maintenance_task(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
diagnosis = await ops_maintenance_service.diagnose_task(db, task_id)
if diagnosis is None:
raise HTTPException(status_code=404, detail="Task not found.")
return diagnosis
@router.post("/tasks/{task_id}/cleanup-preview")
async def preview_maintenance_cleanup(
task_id: str,
_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
preview = await ops_maintenance_service.cleanup_preview(db, task_id)
if preview is None:
raise HTTPException(status_code=404, detail="Task not found.")
return preview
@router.post("/tasks/{task_id}/cleanup")
async def cleanup_maintenance_task(
task_id: str,
request_body: CleanupRequest,
request: Request,
admin_user: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
if not request_body.confirm:
raise HTTPException(status_code=400, detail="Cleanup confirmation is required.")
try:
result = await ops_maintenance_service.cleanup_task(
db,
task_id,
options=request_body.model_dump(),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Task not found.")
await _add_operation_audit_log(
db,
request,
action="ops_task_cleanup",
resource=f"ops-maintenance/tasks/{task_id}",
detail={
"task_id": task_id,
"options": request_body.model_dump(),
"result": result,
},
user=admin_user,
)
await db.commit()
return result
@@ -0,0 +1,695 @@
from __future__ import annotations
import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import (
DinsarProductionExecutionORM,
DinsarProductionRunItemORM,
DinsarProductionRunORM,
DinsarResultORM,
ResultProductORM,
SystemJobORM,
SystemTaskORM,
TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
)
from .dinsar_production_service import dinsar_production_service
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"}
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"}
def _utcnow_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _dt(value: Any) -> Optional[str]:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _norm_status(value: Any) -> str:
return str(value or "").strip().upper()
def _compact(value: Any, limit: int = 220) -> Optional[str]:
if value is None:
return None
text = str(value).replace("\r", " ").replace("\n", " ").strip()
if len(text) <= limit:
return text
return text[:limit] + "..."
def _normalize_path_text(value: Any) -> str:
return os.path.normpath(str(value or "").strip().strip('"').strip("'"))
def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path)
def _safe_count(rows: Iterable[Any]) -> int:
return len(list(rows))
class OpsMaintenanceService:
async def list_tasks(
self,
db: AsyncSession,
*,
task_type: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
safe_limit = max(1, min(int(limit or 50), 200))
safe_offset = max(0, int(offset or 0))
task_types = [task_type.strip().upper()] if task_type else sorted(DEFAULT_TASK_TYPES)
status_filter = status.strip().upper() if status else ""
conditions = [SystemTaskORM.task_type.in_(task_types)]
if status_filter:
conditions.append(SystemTaskORM.status == status_filter)
else:
conditions.append(SystemTaskORM.status.in_(sorted(MAINTENANCE_LIST_STATUSES)))
result = await db.execute(
select(SystemTaskORM)
.where(*conditions)
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
tasks = list(result.scalars().all())
items = [await self._build_task_summary(db, task) for task in tasks]
abnormal_items = [
item
for item in items
if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
]
return {
"items": abnormal_items,
"limit": safe_limit,
"offset": safe_offset,
"returned": len(abnormal_items),
}
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = []
products: List[Dict[str, Any]] = []
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
disk_paths.extend(await self._collect_run_disk_paths(db, run))
products = await self._collect_result_products(db, run)
related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
copy_dest = self._copy_task_dest_dir(task)
if copy_dest:
disk_paths.append(self._path_payload(copy_dest, "task_pool"))
recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=disk_paths,
)
return {
"task": self._task_payload(task),
"jobs": [self._job_payload(job) for job in jobs],
"production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts,
"production_execution_counts": execution_counts,
"result_products": products,
"related_tasks": [self._task_payload(item) for item in related_tasks],
"recent_logs": recent_logs,
"disk_paths": disk_paths,
"diagnosis": {
"summary": findings[0] if findings else "未发现明显异常。",
"findings": findings,
"cleanup_supported": cleanup_supported,
"cleanup_blockers": cleanup_blockers,
},
}
async def cleanup_preview(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
diagnosis = await self.diagnose_task(db, task_id)
if diagnosis is None:
return None
task = diagnosis["task"]
task_type = _norm_status(task.get("task_type"))
task_status = _norm_status(task.get("status"))
cleanup_supported = bool(diagnosis["diagnosis"].get("cleanup_supported"))
blockers = list(diagnosis["diagnosis"].get("cleanup_blockers") or [])
if task_type not in SUPPORTED_CLEANUP_TASK_TYPES:
blockers.append(f"第一版暂不支持清理任务类型 {task_type}")
if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。")
db_counts = await self._cleanup_db_counts(db, diagnosis)
disk_deletes = self._cleanup_disk_targets(diagnosis)
blocked = bool(blockers) or not cleanup_supported
return {
"task_id": task_id,
"blocked": blocked,
"blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked,
"database_deletes": db_counts,
"disk_deletes": disk_deletes,
}
async def cleanup_task(
self,
db: AsyncSession,
task_id: str,
*,
options: Dict[str, bool],
) -> Optional[Dict[str, Any]]:
preview = await self.cleanup_preview(db, task_id)
if preview is None:
return None
if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
deleted_db: Dict[str, int] = {}
task = await self._get_task(db, task_id)
if task is None:
return None
jobs = await self._get_jobs(db, task_id)
run = await self._get_production_run_for_task(db, task, jobs)
delete_logs = bool(options.get("delete_logs", True))
delete_task_records = bool(options.get("delete_task_records", True))
delete_production_records = bool(options.get("delete_production_records", True))
delete_result_products = bool(options.get("delete_result_products", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
if delete_result_products and run is not None:
products = await self._result_product_orms_for_run(db, run)
product_ids = [item.product_id for item in products]
compat_ids = await self._compat_ids_for_products(db, product_ids)
if product_ids:
result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids)))
deleted_db["result_products"] = int(result.rowcount or 0)
if compat_ids:
result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids)))
deleted_db["dinsar_results"] = int(result.rowcount or 0)
if delete_production_records and run is not None:
deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db)
deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0
deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0))
deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0))
deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0))
deleted_db["system_tasks"] = 1
deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0))
if delete_task_pool_dir:
related_deleted = await self._delete_related_tasks(db, related_copy_tasks)
for key, value in related_deleted.items():
deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0)
else:
if delete_logs:
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
deleted_db["task_logs"] = int(result.rowcount or 0)
if delete_task_records:
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
deleted_db["system_jobs"] = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
deleted_db["system_tasks"] = int(result.rowcount or 0)
await db.commit()
disk_result = await self._delete_disk_targets(preview, options)
return {
"task_id": task_id,
"deleted_database": deleted_db,
"deleted_disk": disk_result,
}
async def _delete_related_tasks(
self,
db: AsyncSession,
tasks: List[SystemTaskORM],
) -> Dict[str, int]:
task_ids = [task.task_id for task in tasks if task.task_id]
if not task_ids:
return {}
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
logs = int(result.rowcount or 0)
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids)))
jobs = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
task_count = int(result.rowcount or 0)
await db.commit()
return {
"related_task_logs": logs,
"related_system_jobs": jobs,
"related_system_tasks": task_count,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]:
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none()
async def _get_jobs(self, db: AsyncSession, task_id: str) -> List[SystemJobORM]:
result = await db.execute(
select(SystemJobORM)
.where(SystemJobORM.task_id == str(task_id or "").strip())
.order_by(SystemJobORM.updated_at.desc(), SystemJobORM.id.desc())
)
return list(result.scalars().all())
async def _get_production_run_for_task(
self,
db: AsyncSession,
task: SystemTaskORM,
jobs: List[SystemJobORM],
) -> Optional[DinsarProductionRunORM]:
candidates: List[str] = []
params = task.params or {}
if isinstance(params, dict):
for key in ("production_run_id", "run_id"):
if params.get(key):
candidates.append(str(params[key]))
for job in jobs:
payload = job.payload or {}
if isinstance(payload, dict):
for key in ("production_run_id", "run_id", "dinsar_run_id"):
if payload.get(key):
candidates.append(str(payload[key]))
if candidates:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id.in_(candidates)))
run = result.scalar_one_or_none()
if run is not None:
return run
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.task_id == task.task_id))
return result.scalar_one_or_none()
async def _build_task_summary(self, db: AsyncSession, task: SystemTaskORM) -> Dict[str, Any]:
jobs = await self._get_jobs(db, task.task_id)
run = await self._get_production_run_for_task(db, task, jobs)
item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {}
if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
findings, cleanup_supported, blockers = self._diagnose_findings(
task=task,
jobs=jobs,
run=run,
item_counts=item_counts,
execution_counts=execution_counts,
disk_paths=[],
)
log_count = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task.task_id))
return {
**self._task_payload(task),
"run_id": run.run_id if run else None,
"job_id": jobs[0].job_id if jobs else None,
"job_status": jobs[0].status if jobs else None,
"issue_level": "danger" if _norm_status(task.status) == "FAILED" else ("warning" if findings else "info"),
"issue_summary": findings[0] if findings else _compact(task.message, 160),
"cleanup_supported": cleanup_supported,
"cleanup_blocked_reason": "; ".join(blockers) if blockers else "",
"counts": {
"jobs": len(jobs),
"logs": log_count,
"run_items": sum(item_counts.values()),
"executions": sum(execution_counts.values()),
"completed_items": item_counts.get("COMPLETED", 0),
"failed_items": item_counts.get("FAILED", 0),
"pending_items": item_counts.get("PENDING", 0),
"running_items": item_counts.get("RUNNING", 0),
},
}
async def _status_counts(self, db: AsyncSession, model: Any, run_id: str) -> Dict[str, int]:
result = await db.execute(
select(model.status, func.count())
.where(model.run_id == run_id)
.group_by(model.status)
)
return {str(status or "UNKNOWN").upper(): int(count or 0) for status, count in result.all()}
async def _recent_logs(self, db: AsyncSession, task_id: str) -> List[Dict[str, Any]]:
result = await db.execute(
select(TaskLogORM)
.where(TaskLogORM.task_id == task_id)
.order_by(TaskLogORM.timestamp.desc(), TaskLogORM.id.desc())
.limit(30)
)
return [
{
"id": item.id,
"level": item.log_level,
"message": _compact(item.message, 500),
"timestamp": _dt(item.timestamp),
}
for item in result.scalars().all()
]
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {}
if run.source_root:
paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool")
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all():
if execution.output_dir:
publish_dir = self._publish_package_dir(execution.output_dir)
paths[publish_dir] = self._path_payload(publish_dir, "production_result")
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path")
if log_path:
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log")
return list(paths.values())
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
products = await self._result_product_orms_for_run(db, run)
return [
{
"product_id": item.product_id,
"display_name": item.display_name,
"status": item.status,
"health_status": item.health_status,
"publish_dir": item.publish_dir,
"manifest_path": item.manifest_path,
}
for item in products
]
async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]:
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir]
clauses = []
for path in dirs:
clauses.append(ResultProductORM.publish_dir == path)
clauses.append(ResultProductORM.native_output_dir.like(path + "%"))
clauses.append(ResultProductORM.manifest_path.like(path + "%"))
clauses.append(ResultProductORM.primary_asset_path.like(path + "%"))
if not clauses:
return []
products = await db.execute(select(ResultProductORM).where(or_(*clauses)))
by_id: Dict[str, ResultProductORM] = {}
for product in products.scalars().all():
by_id[product.product_id] = product
return list(by_id.values())
async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]:
if not product_ids:
return []
result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids)))
return [int(item.id) for item in result.scalars().all()]
def _publish_package_dir(self, output_dir: str) -> str:
normalized = _normalize_path_text(output_dir)
marker = os.sep + "runs" + os.sep
if marker.lower() in normalized.lower():
lower = normalized.lower()
index = lower.index(marker.lower())
return normalized[:index]
return normalized
def _copy_task_dest_dir(self, task: SystemTaskORM) -> str:
params = task.params if isinstance(task.params, dict) else {}
return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else ""
async def _related_copy_tasks_for_run(
self,
db: AsyncSession,
run: Optional[DinsarProductionRunORM],
) -> List[SystemTaskORM]:
source_root = _normalize_path_text(run.source_root if run is not None else "")
if not source_root:
return []
result = await db.execute(
select(SystemTaskORM)
.where(SystemTaskORM.task_type == "COPY_DATA")
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc())
.limit(1000)
)
tasks = []
for task in result.scalars().all():
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower():
tasks.append(task)
return tasks
def _diagnose_findings(
self,
*,
task: SystemTaskORM,
jobs: List[SystemJobORM],
run: Optional[DinsarProductionRunORM],
item_counts: Dict[str, int],
execution_counts: Dict[str, int],
disk_paths: List[Dict[str, Any]],
) -> tuple[List[str], bool, List[str]]:
findings: List[str] = []
blockers: List[str] = []
status = _norm_status(task.status)
if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,清理前请确认保留策略。")
elif status == "CANCELLED":
findings.append("任务已取消,可按需清理残留记录和目录。")
elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。")
active_jobs = [job for job in jobs if _norm_status(job.status) in ACTIVE_JOB_STATUSES]
stale_cutoff = _utcnow_naive() - timedelta(seconds=int(getattr(settings, "JOB_WORKER_STALE_RUNNING_SECONDS", 7200)))
stale_jobs = [
job for job in active_jobs
if job.heartbeat_at is not None and job.heartbeat_at < stale_cutoff
]
if stale_jobs:
findings.append(f"发现 {len(stale_jobs)} 个心跳超时 job。")
if active_jobs and not stale_jobs:
blockers.append("仍存在活动 job。")
residual_running = item_counts.get("RUNNING", 0) + execution_counts.get("RUNNING", 0)
if run is not None and residual_running:
findings.append(f"生产 run 中仍有 {residual_running} 个 RUNNING 残留。")
if run is not None and item_counts.get("PENDING", 0):
findings.append(f"生产 run 中仍有 {item_counts.get('PENDING', 0)} 个 PENDING 项未执行。")
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths:
findings.append(f"{len(missing_paths)} 个登记路径已不存在。")
cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers
return findings, cleanup_supported, blockers
async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]:
task_id = diagnosis["task"]["task_id"]
run = diagnosis.get("production_run") or {}
run_id = run.get("run_id")
workflow_run_id = run.get("workflow_run_id")
counts = {
"system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)),
"system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)),
"task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)),
"related_system_tasks": 0,
"related_system_jobs": 0,
"related_task_logs": 0,
"dinsar_production_runs": 0,
"dinsar_production_run_items": 0,
"dinsar_production_executions": 0,
"result_products": len(diagnosis.get("result_products") or []),
"dinsar_results": 0,
"workflow_runs": 0,
"workflow_steps": 0,
"workflow_artifacts": 0,
}
if run_id:
counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id))
counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id))
run_obj = await self._get_run_by_id(db, run_id)
related_tasks = await self._related_copy_tasks_for_run(db, run_obj)
related_task_ids = [item.task_id for item in related_tasks if item.task_id]
counts["related_system_tasks"] = len(related_task_ids)
if related_task_ids:
counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids)))
counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids)))
if workflow_run_id:
counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
return counts
async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
return result.scalar_one_or_none()
async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []:
path = _normalize_path_text(item.get("path"))
if not path:
continue
payload = self._path_payload(path, item.get("kind") or "unknown")
payload["allowed"] = self._is_allowed_delete_path(path)
targets[path.lower()] = payload
return list(targets.values())
def _path_payload(self, path: str, kind: str) -> Dict[str, Any]:
normalized = _normalize_path_text(path)
exists = _path_exists(normalized)
return {
"path": normalized,
"kind": kind,
"exists": exists,
"allowed": self._is_allowed_delete_path(normalized),
}
def _is_allowed_delete_path(self, path: str) -> bool:
normalized = _normalize_path_text(path)
if not normalized:
return False
roots = [
settings.DINSAR_TASK_POOL_ROOT,
settings.DINSAR_PRODUCT_DIR,
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"),
]
full = os.path.abspath(normalized)
for root in roots:
root_text = _normalize_path_text(root)
if not root_text:
continue
root_full = os.path.abspath(root_text)
if full == root_full:
return False
try:
if os.path.commonpath([full, root_full]) == root_full:
return True
except ValueError:
continue
return False
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_production_dirs = bool(options.get("delete_production_dirs", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
deleted: List[str] = []
missing: List[str] = []
skipped: List[str] = []
failed: List[Dict[str, str]] = []
for item in preview.get("disk_deletes") or []:
kind = item.get("kind")
path = _normalize_path_text(item.get("path"))
if not item.get("allowed"):
skipped.append(path)
continue
if kind == "task_pool" and not delete_task_pool_dir:
skipped.append(path)
continue
if kind == "production_result" and not delete_production_dirs:
skipped.append(path)
continue
if not _path_exists(path):
missing.append(path)
continue
try:
if os.path.isdir(path):
shutil.rmtree(path)
else:
Path(path).unlink()
deleted.append(path)
except OSError as exc:
failed.append({"path": path, "error": str(exc)})
return {
"deleted": deleted,
"missing": missing,
"skipped": skipped,
"failed": failed,
}
def _task_payload(self, task: SystemTaskORM) -> Dict[str, Any]:
return {
"id": task.id,
"task_id": task.task_id,
"task_type": task.task_type,
"task_name": task.task_name,
"status": task.status,
"progress": task.progress,
"message": _compact(task.message),
"params": task.params,
"created_at": _dt(task.created_at),
"updated_at": _dt(task.updated_at),
"started_at": _dt(task.started_at),
"ended_at": _dt(task.ended_at),
}
def _job_payload(self, job: SystemJobORM) -> Dict[str, Any]:
return {
"job_id": job.job_id,
"job_type": job.job_type,
"status": job.status,
"attempts": job.attempts,
"max_attempts": job.max_attempts,
"locked_by": job.locked_by,
"locked_at": _dt(job.locked_at),
"heartbeat_at": _dt(job.heartbeat_at),
"started_at": _dt(job.started_at),
"finished_at": _dt(job.finished_at),
"last_error": _compact(job.last_error),
}
def _run_payload(self, run: DinsarProductionRunORM) -> Dict[str, Any]:
return {
"run_id": run.run_id,
"task_id": run.task_id,
"workflow_run_id": run.workflow_run_id,
"engine_code": run.engine_code,
"profile_code": run.profile_code,
"mode": run.mode,
"source_root": run.source_root,
"publish_root_dir": run.publish_root_dir,
"status": run.status,
"total_items": run.total_items,
"completed_items": run.completed_items,
"failed_items": run.failed_items,
"skipped_items": run.skipped_items,
"latest_message": _compact(run.latest_message),
"created_at": _dt(run.created_at),
"updated_at": _dt(run.updated_at),
"started_at": _dt(run.started_at),
"ended_at": _dt(run.ended_at),
}
ops_maintenance_service = OpsMaintenanceService()
+280
View File
@@ -3109,6 +3109,269 @@ input[type="checkbox"] {
cursor: not-allowed; cursor: not-allowed;
} }
.health-action-button.danger {
border-color: #b91c1c;
background: #b91c1c;
}
.ops-task-panel {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: #fff;
}
.ops-task-header,
.ops-task-detail-header,
.ops-task-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.ops-task-header h4,
.ops-task-detail-header h4 {
margin: 0;
color: var(--color-text-primary);
font-size: 0.95em;
}
.ops-task-header p,
.ops-task-detail-header p {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 0.82em;
line-height: 1.5;
}
.ops-task-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ops-task-summary > div,
.ops-task-preview-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
.ops-task-summary span,
.ops-task-preview-cell span {
color: #64748b;
font-size: 0.78em;
}
.ops-task-summary strong,
.ops-task-preview-cell strong {
color: #0f172a;
font-size: 0.9em;
}
.ops-task-filters {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ops-task-filters label {
display: grid;
gap: 4px;
color: #475569;
font-size: 0.78em;
font-weight: 700;
}
.ops-task-filters select {
min-width: 150px;
padding: 7px 9px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
}
.ops-task-table-wrap {
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.ops-task-table {
width: 100%;
min-width: 880px;
border-collapse: collapse;
font-size: 0.82em;
}
.ops-task-table th,
.ops-task-table td {
padding: 9px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
vertical-align: top;
}
.ops-task-table th {
color: #475569;
background: #f8fafc;
font-weight: 800;
}
.ops-task-table tr.selected td {
background: #eff6ff;
}
.ops-task-table tr:last-child td {
border-bottom: 0;
}
.ops-task-name {
color: #0f172a;
font-weight: 800;
}
.ops-task-muted {
color: #64748b;
font-size: 0.82em;
line-height: 1.5;
}
.ops-task-empty {
color: #64748b;
text-align: center !important;
}
.ops-task-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.76em;
font-weight: 800;
white-space: nowrap;
}
.ops-task-badge.tone-danger {
background: #fee2e2;
color: #991b1b;
}
.ops-task-badge.tone-warn {
background: #fef3c7;
color: #92400e;
}
.ops-task-badge.tone-ok {
background: #dcfce7;
color: #166534;
}
.ops-task-badge.tone-info {
background: #dbeafe;
color: #1d4ed8;
}
.ops-task-badge.tone-neutral {
background: #e2e8f0;
color: #334155;
}
.ops-task-detail,
.ops-task-preview {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid #dbe3ef;
border-radius: 8px;
background: #fbfdff;
}
.ops-task-diagnosis {
display: grid;
gap: 6px;
}
.ops-task-two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.ops-task-section-title {
margin-bottom: 6px;
color: #334155;
font-size: 0.82em;
font-weight: 800;
}
.ops-task-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px;
}
.ops-task-path-list,
.ops-task-log-list {
display: grid;
gap: 6px;
}
.ops-task-path {
display: grid;
gap: 4px;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
}
.ops-task-path.blocked {
border-color: #fecaca;
background: #fff7f7;
}
.ops-task-path span,
.ops-task-path em,
.ops-task-log-list span {
color: #64748b;
font-size: 0.76em;
font-style: normal;
}
.ops-task-path code {
color: #0f172a;
font-size: 0.78em;
white-space: normal;
overflow-wrap: anywhere;
}
.ops-task-log-list > div {
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
}
.ops-task-log-list p {
margin: 3px 0 0;
color: #334155;
font-size: 0.8em;
line-height: 1.45;
}
.log-management-panel { .log-management-panel {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -3460,6 +3723,23 @@ input[type="checkbox"] {
.health-grid { .health-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.ops-task-header,
.ops-task-detail-header,
.ops-task-actions {
align-items: stretch;
flex-direction: column;
}
.ops-task-summary,
.ops-task-two-col {
grid-template-columns: 1fr;
}
.ops-task-filters label,
.ops-task-filters select {
width: 100%;
}
} }
/* Top Status Bar */ /* Top Status Bar */
+2
View File
@@ -7,6 +7,7 @@ import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction'; import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit'; import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel'; import LogManagementPanel from './LogManagementPanel';
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
const toNumber = (value) => { const toNumber = (value) => {
const parsed = Number(value); const parsed = Number(value);
@@ -1507,6 +1508,7 @@ const HealthCheckPanel = ({ currentUser }) => {
)} )}
{/* 日志管理 */} {/* 日志管理 */}
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} /> <LogManagementPanel isAdmin={isAdmin} />
</div> </div>
</section> </section>
+368
View File
@@ -0,0 +1,368 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
cleanupMaintenanceTask,
getMaintenanceTaskDiagnosis,
listMaintenanceTasks,
previewMaintenanceCleanup,
} from './api/opsMaintenance';
const TASK_TYPES = [
['', '全部类型'],
['LANDSAR_RUN', 'LandSAR D-InSAR'],
['LANDSAR_CLUSTER_RUN', 'LandSAR 集群'],
['PYINT_RUN', 'PyINT/Gamma'],
['IDL_RUN_DINSAR', 'SARscape D-InSAR'],
['COPY_DATA', '数据准备'],
['PAIRING_CACHE_REBUILD', '配对缓存'],
];
const STATUSES = [
['', '全部状态'],
['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'],
['RUNNING', '运行中'],
['PENDING', '等待中'],
];
const formatDate = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
};
const formatCount = (value) => Number(value || 0).toLocaleString('zh-CN');
const toneForStatus = (status) => {
const value = String(status || '').toUpperCase();
if (value === 'FAILED') return 'danger';
if (value === 'PARTIAL_SUCCESS' || value === 'CANCELLED') return 'warn';
if (value === 'COMPLETED') return 'ok';
if (value === 'RUNNING' || value === 'PENDING') return 'info';
return 'neutral';
};
const StatusBadge = ({ status }) => (
<span className={`ops-task-badge tone-${toneForStatus(status)}`}>{status || '-'}</span>
);
const CountLine = ({ counts = {} }) => (
<span>
成功 {formatCount(counts.completed_items)} / 失败 {formatCount(counts.failed_items)} / 等待 {formatCount(counts.pending_items)} / 运行 {formatCount(counts.running_items)}
</span>
);
const DatabasePreview = ({ counts = {} }) => {
const entries = Object.entries(counts).filter(([, value]) => Number(value || 0) > 0);
if (!entries.length) return <div className="ops-task-muted">没有将删除的数据库记录</div>;
return (
<div className="ops-task-preview-grid">
{entries.map(([key, value]) => (
<div key={key} className="ops-task-preview-cell">
<span>{key}</span>
<strong>{formatCount(value)}</strong>
</div>
))}
</div>
);
};
const DiskPreview = ({ paths = [] }) => {
if (!paths.length) return <div className="ops-task-muted">没有将删除的磁盘路径</div>;
return (
<div className="ops-task-path-list">
{paths.map(item => (
<div key={`${item.kind}-${item.path}`} className={`ops-task-path ${item.allowed ? '' : 'blocked'}`}>
<span>{item.kind || 'path'}</span>
<code>{item.path}</code>
<em>{item.exists ? '存在' : '不存在'} / {item.allowed ? '允许' : '禁止'}</em>
</div>
))}
</div>
);
};
const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [filters, setFilters] = useState({ task_type: '', status: '' });
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [selectedTaskId, setSelectedTaskId] = useState('');
const [diagnosis, setDiagnosis] = useState(null);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await listMaintenanceTasks({
task_type: filters.task_type || undefined,
status: filters.status || undefined,
limit: 80,
});
setTasks(Array.isArray(data?.items) ? data.items : []);
} catch (err) {
setError(err.response?.data?.detail || err.message || '任务维护列表加载失败');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const selectedTask = useMemo(
() => tasks.find(item => item.task_id === selectedTaskId) || null,
[tasks, selectedTaskId],
);
const loadDiagnosis = useCallback(async (taskId) => {
setSelectedTaskId(taskId);
setDiagnosis(null);
setPreview(null);
setCleanupResult(null);
setPanelMessage(null);
setDiagnosisLoading(true);
try {
const data = await getMaintenanceTaskDiagnosis(taskId);
setDiagnosis(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '诊断加载失败' });
} finally {
setDiagnosisLoading(false);
}
}, []);
const loadPreview = useCallback(async () => {
if (!selectedTaskId) return;
setPreviewLoading(true);
setCleanupResult(null);
try {
const data = await previewMaintenanceCleanup(selectedTaskId);
setPreview(data);
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理预览失败' });
} finally {
setPreviewLoading(false);
}
}, [selectedTaskId]);
const executeCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setCleanupLoading(true);
setCleanupResult(null);
try {
const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true,
delete_task_records: true,
delete_logs: true,
delete_production_records: true,
delete_result_products: true,
delete_production_dirs: true,
delete_task_pool_dir: true,
});
setCleanupResult({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPanelMessage({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPreview(null);
setDiagnosis(null);
setSelectedTaskId('');
await loadTasks();
} catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' });
} finally {
setCleanupLoading(false);
}
}, [isAdmin, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
const blockedCount = tasks.filter(item => !item.cleanup_supported).length;
return (
<div className="ops-task-panel">
<div className="ops-task-header">
<div>
<h4>任务维护</h4>
<p>查看失败部分成功取消和残留运行的任务清理后请回到业务页面重新提交</p>
</div>
<button className="health-action-button" onClick={loadTasks} disabled={loading}>
{loading ? '刷新中...' : '刷新任务'}
</button>
</div>
<div className="ops-task-summary">
<div><span>异常任务</span><strong>{formatCount(abnormalCount)}</strong></div>
<div><span>可清理</span><strong>{formatCount(cleanableCount)}</strong></div>
<div><span>需复核</span><strong>{formatCount(blockedCount)}</strong></div>
</div>
<div className="ops-task-filters">
<label>
<span>任务类型</span>
<select value={filters.task_type} onChange={event => setFilters(prev => ({ ...prev, task_type: event.target.value }))}>
{TASK_TYPES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
<label>
<span>状态</span>
<select value={filters.status} onChange={event => setFilters(prev => ({ ...prev, status: event.target.value }))}>
{STATUSES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
</label>
</div>
{error && <div className="health-card-note error">{error}</div>}
{panelMessage && (
<div className={`health-card-note ${panelMessage.ok ? 'ok' : 'error'}`}>
{panelMessage.message}
</div>
)}
<div className="ops-task-table-wrap">
<table className="ops-task-table">
<thead>
<tr>
<th>状态</th>
<th>任务</th>
<th>统计</th>
<th>问题摘要</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.task_id} className={task.task_id === selectedTaskId ? 'selected' : ''}>
<td><StatusBadge status={task.status} /></td>
<td>
<div className="ops-task-name">{task.task_name || task.task_id}</div>
<div className="ops-task-muted">{task.task_type} / {task.task_id}</div>
</td>
<td><CountLine counts={task.counts} /></td>
<td>{task.issue_summary || '-'}</td>
<td>{formatDate(task.updated_at)}</td>
<td>
<button className="health-inline-button" onClick={() => loadDiagnosis(task.task_id)}>
诊断
</button>
</td>
</tr>
))}
{!loading && !tasks.length && (
<tr>
<td colSpan={6} className="ops-task-empty">当前没有匹配的异常任务</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedTaskId && (
<div className="ops-task-detail">
<div className="ops-task-detail-header">
<div>
<h4>{selectedTask?.task_name || selectedTaskId}</h4>
<p>{selectedTaskId}</p>
</div>
<button className="health-inline-button" onClick={loadPreview} disabled={previewLoading || diagnosisLoading}>
{previewLoading ? '生成中...' : '生成清理预览'}
</button>
</div>
{diagnosisLoading ? (
<div className="ops-task-muted">正在加载诊断...</div>
) : diagnosis ? (
<>
<div className="ops-task-diagnosis">
{(diagnosis.diagnosis?.findings || []).map(item => (
<div key={item} className="health-card-note warn">{item}</div>
))}
{!(diagnosis.diagnosis?.findings || []).length && (
<div className="health-card-note ok">未发现明显异常</div>
)}
{(diagnosis.diagnosis?.cleanup_blockers || []).map(item => (
<div key={item} className="health-card-note error">{item}</div>
))}
</div>
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">生产统计</div>
<DatabasePreview counts={{
...diagnosis.production_item_counts,
...Object.fromEntries(Object.entries(diagnosis.production_execution_counts || {}).map(([key, value]) => [`execution_${key}`, value])),
}} />
</div>
<div>
<div className="ops-task-section-title">最近日志</div>
<div className="ops-task-log-list">
{(diagnosis.recent_logs || []).slice(0, 8).map(log => (
<div key={log.id}>
<span>[{formatDate(log.timestamp)}] {log.level}</span>
<p>{log.message}</p>
</div>
))}
{!(diagnosis.recent_logs || []).length && <div className="ops-task-muted">无日志</div>}
</div>
</div>
</div>
</>
) : null}
{preview && (
<div className="ops-task-preview">
<div className="ops-task-section-title">清理预览</div>
{preview.blocked && (
<div className="health-card-note error">
{preview.blockers?.join('') || '当前任务不允许清理。'}
</div>
)}
<div className="ops-task-two-col">
<div>
<div className="ops-task-section-title">数据库记录</div>
<DatabasePreview counts={preview.database_deletes} />
</div>
<div>
<div className="ops-task-section-title">磁盘路径</div>
<DiskPreview paths={preview.disk_deletes} />
</div>
</div>
<div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理</span>}
<button
className="health-action-button danger"
onClick={executeCleanup}
disabled={!isAdmin || cleanupLoading || preview.blocked}
>
{cleanupLoading ? '清理中...' : '确认清理'}
</button>
</div>
</div>
)}
{cleanupResult && (
<div className={`health-card-note ${cleanupResult.ok ? 'ok' : 'error'}`}>
{cleanupResult.message}
</div>
)}
</div>
)}
</div>
);
};
export default OpsTaskMaintenancePanel;
+13
View File
@@ -0,0 +1,13 @@
import apiClient from './client';
export const listMaintenanceTasks = (params = {}) =>
apiClient.get('/ops-maintenance/tasks', { params }).then(r => r.data);
export const getMaintenanceTaskDiagnosis = (taskId) =>
apiClient.get(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/diagnosis`).then(r => r.data);
export const previewMaintenanceCleanup = (taskId) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup-preview`).then(r => r.data);
export const cleanupMaintenanceTask = (taskId, payload) =>
apiClient.post(`/ops-maintenance/tasks/${encodeURIComponent(taskId)}/cleanup`, payload).then(r => r.data);