Refactor: Unified pipeline execution, simplified UI, and fixed Docker config
- Backend: Refactored tasks.py to directly invoke run_single_fna_pipeline.py for consistency. - Backend: Changed output format to ZIP and added auto-cleanup of intermediate files. - Backend: Fixed language parameter passing in API and tasks. - Frontend: Removed CRISPR Fusion UI elements from Submit and Monitor views. - Frontend: Implemented simulated progress bar for better UX. - Frontend: Restored One-click load button and added result file structure documentation. - Docker: Fixed critical Restarting loop by removing incorrect image directive in docker-compose.yml. - Docker: Optimized Dockerfile to correct .pixi environment path issues and prevent accidental deletion of frontend assets.
This commit is contained in:
60
backend/app/api/v1/examples.py
Normal file
60
backend/app/api/v1/examples.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Define the path to examples relative to this file
|
||||
# This file: backend/app/api/v1/examples.py
|
||||
# Root: backend/app/api/v1/../../../../.. = root (where Data/ is)
|
||||
# But in Docker, backend/ is at /app/backend and Data/ is at /app/Data
|
||||
# So we need to go up 5 levels to get to /app (or project root)
|
||||
|
||||
current_file = Path(__file__).resolve()
|
||||
# Go up 5 levels: v1 -> api -> app -> backend -> root
|
||||
PROJECT_ROOT = current_file.parent.parent.parent.parent.parent
|
||||
EXAMPLES_DIR = PROJECT_ROOT / "Data" / "examples"
|
||||
|
||||
@router.get("/")
|
||||
async def list_examples():
|
||||
"""List available example files"""
|
||||
if not EXAMPLES_DIR.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
# Sort files to ensure consistent order (e.g. C15.fna, then 97-27.fna)
|
||||
# Actually just grab all .fna files
|
||||
if EXAMPLES_DIR.exists():
|
||||
for f in sorted(EXAMPLES_DIR.glob("*.fna")):
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size
|
||||
})
|
||||
return files
|
||||
|
||||
@router.get("/{filename}")
|
||||
async def get_example(filename: str):
|
||||
"""Get an example file content"""
|
||||
file_path = EXAMPLES_DIR / filename
|
||||
|
||||
# Ensure directory exists
|
||||
if not EXAMPLES_DIR.exists():
|
||||
raise HTTPException(status_code=404, detail="Examples directory not found")
|
||||
|
||||
# Security check to prevent path traversal
|
||||
try:
|
||||
if not file_path.resolve().is_relative_to(EXAMPLES_DIR.resolve()):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
except ValueError:
|
||||
# Can happen if paths are on different drives or completely messed up
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream"
|
||||
)
|
||||
@@ -31,8 +31,7 @@ async def create_job(
|
||||
min_coverage: float = Form(0.6),
|
||||
allow_unknown_families: bool = Form(False),
|
||||
require_index_hit: bool = Form(True),
|
||||
crispr_fusion: bool = Form(False),
|
||||
crispr_weight: float = Form(0.0),
|
||||
lang: str = Form("zh"),
|
||||
db: Session = Depends(get_db),
|
||||
i18n: I18n = Depends(get_i18n)
|
||||
):
|
||||
@@ -48,6 +47,7 @@ async def create_job(
|
||||
min_coverage: 最小覆盖度 (0-1)
|
||||
allow_unknown_families: 是否允许未知家族
|
||||
require_index_hit: 是否要求索引命中
|
||||
lang: 报告语言 (zh/en)
|
||||
"""
|
||||
# 验证文件类型
|
||||
allowed_extensions = {".fna", ".fa", ".fasta", ".faa"}
|
||||
@@ -91,8 +91,6 @@ async def create_job(
|
||||
min_coverage=int(min_coverage * 100),
|
||||
allow_unknown_families=int(allow_unknown_families),
|
||||
require_index_hit=int(require_index_hit),
|
||||
crispr_fusion=int(crispr_fusion),
|
||||
crispr_weight=int(crispr_weight * 100),
|
||||
)
|
||||
|
||||
db.add(job)
|
||||
@@ -111,8 +109,7 @@ async def create_job(
|
||||
min_coverage=min_coverage,
|
||||
allow_unknown_families=allow_unknown_families,
|
||||
require_index_hit=require_index_hit,
|
||||
crispr_fusion=crispr_fusion,
|
||||
crispr_weight=crispr_weight,
|
||||
lang=lang,
|
||||
)
|
||||
|
||||
job.celery_task_id = task.id
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""结果下载 API"""
|
||||
from fastapi import APIRouter, HTTPException, Response, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import io
|
||||
import shutil
|
||||
import json
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.job import Job, JobStatus
|
||||
@@ -53,6 +54,39 @@ async def download_results(job_id: str, db: Session = Depends(get_db), i18n: I18
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}/crispr")
|
||||
async def get_crispr_results(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
|
||||
"""获取 CRISPR 分析结果"""
|
||||
job = db.query(Job).filter(Job.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
|
||||
|
||||
if not job.crispr_fusion:
|
||||
# 即使没启用,如果用户请求了,我们也可以返回一个提示或者空数据,但400可能更合适
|
||||
# 或者仅仅返回空对象
|
||||
return {"status": "disabled", "message": "CRISPR analysis disabled"}
|
||||
|
||||
job_output_dir = RESULTS_DIR / job_id
|
||||
|
||||
# 优先返回融合分析结果
|
||||
fusion_file = job_output_dir / "crispr" / "fusion_analysis.json"
|
||||
if fusion_file.exists():
|
||||
with open(fusion_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
# 其次返回检测结果
|
||||
detect_file = job_output_dir / "crispr" / "results.json"
|
||||
if detect_file.exists():
|
||||
with open(detect_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
# 如果任务已完成但文件不存在
|
||||
if job.status == JobStatus.COMPLETED:
|
||||
return {"status": "empty", "message": "No CRISPR elements detected"}
|
||||
|
||||
return {"status": "pending", "message": "Analysis in progress"}
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
|
||||
"""删除任务及其结果"""
|
||||
@@ -60,6 +94,18 @@ async def delete_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = De
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
|
||||
|
||||
# 如果任务正在运行或排队,尝试取消 Celery 任务
|
||||
if job.status in [JobStatus.PENDING, JobStatus.QUEUED, JobStatus.RUNNING] and job.celery_task_id:
|
||||
try:
|
||||
from ...core.celery_app import celery_app
|
||||
celery_app.control.revoke(job.celery_task_id, terminate=True)
|
||||
# 标记为已取消 (虽然后面马上删除了,但为了逻辑完整性)
|
||||
job.status = JobStatus.FAILED
|
||||
job.error_message = "Task cancelled by user"
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"Failed to revoke task {job.celery_task_id}: {e}")
|
||||
|
||||
# 删除磁盘上的文件
|
||||
job_input_dir = Path(settings.UPLOAD_DIR) / job_id
|
||||
job_output_dir = RESULTS_DIR / job_id
|
||||
|
||||
Reference in New Issue
Block a user