4e3a4b4602
带 Web 操作界面的备份系统,支持: - 数据源:MySQL 数据库(mysqldump)、服务器目录(tar.gz) - 存储目标:本地目录、S3 兼容对象存储(MinIO/Ceph/AWS S3) - 触发方式:手动 + Cron 定时调度(APScheduler) - 备份还原:MySQL 库、目录 - JWT 登录认证 + Fernet 字段加密 - 保留策略:按数量 + 按天数双重清理 技术栈:FastAPI + SQLAlchemy 2 + APScheduler + aioboto3; 前端 Vite + React 18 + TypeScript + Ant Design 5 + Zustand。 Docker Compose 一键起。 Co-Authored-By: Claude <noreply@anthropic.com>
97 lines
2.9 KiB
Python
97 lines
2.9 KiB
Python
"""备份运行历史 API。"""
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from app.core.storage.factory import create_storage
|
|
from app.deps import CurrentUser, DBSession
|
|
from app.models.job import Job
|
|
from app.models.run import Run, RunLog
|
|
from app.schemas.run import RunListResponse, RunLogOut, RunOut
|
|
|
|
router = APIRouter(prefix="/runs", tags=["runs"])
|
|
|
|
|
|
@router.get("", response_model=RunListResponse)
|
|
def list_runs(
|
|
db: DBSession,
|
|
_: CurrentUser,
|
|
job_id: int | None = None,
|
|
status: str | None = None,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
) -> RunListResponse:
|
|
page = max(1, page)
|
|
page_size = max(1, min(100, page_size))
|
|
|
|
q = db.query(Run)
|
|
if job_id is not None:
|
|
q = q.filter(Run.job_id == job_id)
|
|
if status:
|
|
q = q.filter(Run.status == status)
|
|
total = q.count()
|
|
items = (
|
|
q.order_by(Run.id.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
return RunListResponse(total=total, items=[RunOut.model_validate(r) for r in items])
|
|
|
|
|
|
@router.get("/{run_id}", response_model=RunOut)
|
|
def get_run(run_id: int, db: DBSession, _: CurrentUser) -> Run:
|
|
run = db.get(Run, run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return run
|
|
|
|
|
|
@router.get("/{run_id}/log", response_model=RunLogOut)
|
|
def get_run_log(run_id: int, db: DBSession, _: CurrentUser) -> RunLog:
|
|
run = db.get(Run, run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
log = db.query(RunLog).filter(RunLog.run_id == run_id).order_by(RunLog.id.desc()).first()
|
|
if not log:
|
|
raise HTTPException(status_code=404, detail="No log")
|
|
return log
|
|
|
|
|
|
@router.get("/{run_id}/download")
|
|
async def download_run(run_id: int, db: DBSession, _: CurrentUser):
|
|
run = db.get(Run, run_id)
|
|
if not run or not run.artifact_path:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
job = db.get(Job, run.job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
storage = create_storage(job.storage)
|
|
|
|
filename = run.artifact_path.split("/")[-1]
|
|
|
|
async def stream():
|
|
async for chunk in storage.download_stream(run.artifact_path):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
stream(),
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.delete("/{run_id}", status_code=204)
|
|
async def delete_run(run_id: int, db: DBSession, _: CurrentUser) -> None:
|
|
run = db.get(Run, run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
job = db.get(Job, run.job_id)
|
|
if job and run.artifact_path:
|
|
try:
|
|
storage = create_storage(job.storage)
|
|
await storage.delete(run.artifact_path)
|
|
except Exception:
|
|
pass
|
|
db.delete(run)
|
|
db.commit()
|