feat: 数据备份管理系统 v0.1.0
带 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>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# ====== 必须设置 ======
|
||||
# JWT 签名密钥:openssl rand -hex 32
|
||||
SECRET_KEY=please-change-me-to-a-random-32-byte-hex-string
|
||||
|
||||
# Fernet 字段加密密钥:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
FERNET_KEY=please-generate-a-fernet-key-and-paste-here
|
||||
|
||||
# 首次启动创建 admin 账号的初始密码,启动后可修改
|
||||
INITIAL_ADMIN_PASSWORD=ChangeMe123!
|
||||
|
||||
# ====== 可选配置 ======
|
||||
JWT_EXPIRE_MINUTES=1440
|
||||
DATABASE_URL=sqlite:///./data/backup.db
|
||||
STORAGE_LOCAL_ROOT=./data/backups
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# CORS:生产环境请填写前端实际域名
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
|
||||
# 环境与密钥
|
||||
.env
|
||||
.env.local
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# 数据与日志
|
||||
backend/data/
|
||||
backend/logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backups/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
@@ -0,0 +1,50 @@
|
||||
.PHONY: help init keys up down logs restart build rebuild backend frontend clean
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make init - 复制 .env.example 到 .env(如不存在)"
|
||||
@echo " make keys - 生成 SECRET_KEY 和 FERNET_KEY 并写入 .env"
|
||||
@echo " make up - 启动所有服务(首次会自动 build)"
|
||||
@echo " make down - 停止所有服务"
|
||||
@echo " make logs - 跟踪查看 backend 日志"
|
||||
@echo " make restart - 重启所有服务"
|
||||
@echo " make build - 构建镜像"
|
||||
@echo " make rebuild - 强制重新构建(无缓存)"
|
||||
@echo " make clean - 停止服务并清理 volumes(会删除数据!)"
|
||||
|
||||
init:
|
||||
@if [ ! -f .env ]; then cp .env.example .env && echo "[ok] .env created"; else echo "[skip] .env already exists"; fi
|
||||
|
||||
keys:
|
||||
@if grep -q "please-change-me" .env 2>/dev/null; then \
|
||||
echo "Generating SECRET_KEY..."; \
|
||||
sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$$(openssl rand -hex 32)|" .env; \
|
||||
echo "Generating FERNET_KEY..."; \
|
||||
sed -i "s|^FERNET_KEY=.*|FERNET_KEY=$$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')|" .env; \
|
||||
echo "[ok] keys generated"; \
|
||||
else \
|
||||
echo "[skip] .env already has real keys"; \
|
||||
fi
|
||||
|
||||
up:
|
||||
docker compose up -d --build
|
||||
@echo "[ok] services started. Frontend: http://localhost:5173 Backend: http://localhost:8000"
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f backend
|
||||
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
rebuild:
|
||||
docker compose build --no-cache
|
||||
|
||||
clean:
|
||||
docker compose down -v
|
||||
@echo "[warn] volumes removed - all backup metadata and local backups deleted!"
|
||||
@@ -0,0 +1,85 @@
|
||||
# 数据备份管理系统
|
||||
|
||||
一个带 Web 操作界面的备份系统,支持将 **MySQL 数据库** 和 **服务器目录** 备份到 **本地目录** 或 **S3 兼容对象存储**(AWS S3 / MinIO / Ceph)。支持手动触发、Cron 定时调度、备份保留策略,以及从备份还原。
|
||||
|
||||
## 功能
|
||||
|
||||
- 📦 **多数据源**:MySQL 数据库(mysqldump)、服务器目录(tar.gz)
|
||||
- ☁️ **多存储目标**:本地目录、S3 兼容对象存储
|
||||
- ⏰ **Cron 定时调度**:标准 cron 表达式
|
||||
- 🔁 **备份还原**:从备份恢复 MySQL 库 / 解压目录
|
||||
- 🗂️ **保留策略**:保留最近 N 个 + 保留 N 天
|
||||
- 👤 **JWT 登录认证**:单用户/多用户管理后台
|
||||
- 📊 **运行历史与日志**:每次备份的执行详情、SHA256 校验、大小、耗时
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Python 3.11、FastAPI、SQLAlchemy 2、APScheduler、boto3/aioboto3
|
||||
- **前端**:Vite + React 18 + TypeScript + Ant Design 5
|
||||
- **数据库**:SQLite(可平滑迁移 PostgreSQL)
|
||||
- **部署**:Docker Compose
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 准备环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make keys # 自动生成 SECRET_KEY 和 FERNET_KEY
|
||||
```
|
||||
|
||||
或手动编辑 `.env`,至少填入以下三项:
|
||||
|
||||
```bash
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
FERNET_KEY=<python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'>
|
||||
INITIAL_ADMIN_PASSWORD=<your-strong-password>
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
- 前端:**http://localhost:5173**
|
||||
- 后端 API:**http://localhost:8000**
|
||||
- API 文档:**http://localhost:8000/docs**
|
||||
|
||||
### 3. 首次使用
|
||||
|
||||
1. 打开 `http://localhost:5173`,用 `admin` / `INITIAL_ADMIN_PASSWORD` 登录
|
||||
2. **Storages** → 新建一个存储目标(Local 路径或 S3 桶)
|
||||
3. **Jobs → 新建** → 选择数据源类型(MySQL / 目录)→ 填写源信息 → 选存储目标 → 保存
|
||||
4. Job 列表点 **Run Now** 立即触发,或填写 `cron_expression` 启用定时调度
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
make logs # 查看后端日志
|
||||
make restart # 重启服务
|
||||
make down # 停止服务
|
||||
make clean # 停止并清理所有数据
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── Makefile
|
||||
├── backend/ # FastAPI 后端
|
||||
└── frontend/ # React 前端
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ⚠️ **单实例部署**:内置 APScheduler,**不要启动多个 backend 副本**,否则会出现重复调度。多副本需要改造为分布式锁。
|
||||
- 🔐 **敏感字段加密**:MySQL 密码和 S3 SecretKey 在数据库中以 Fernet 加密存储,前端永远看不到明文。
|
||||
- 💾 **数据持久化**:所有元数据在 `backend-data` 卷,本地备份在 `local-backups` 卷。删除前请先 `make down` 备份。
|
||||
- 🗄️ **mysqldump 必须可用**:备份 MySQL 时后端容器需能调用 `mysqldump`(官方镜像已安装)。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,9 @@
|
||||
# 同根目录 .env,这里只是文档。实际加载根目录 .env。
|
||||
SECRET_KEY=
|
||||
FERNET_KEY=
|
||||
INITIAL_ADMIN_PASSWORD=ChangeMe123!
|
||||
JWT_EXPIRE_MINUTES=1440
|
||||
DATABASE_URL=sqlite:///./data/backup.db
|
||||
STORAGE_LOCAL_ROOT=./data/backups
|
||||
LOG_LEVEL=INFO
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# 系统依赖:curl 用于健康检查,default-mysql-client 提供 mysqldump,tar 已内置
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
default-mysql-client \
|
||||
tar \
|
||||
gzip \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先安装依赖以利用 Docker 缓存
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 数据、日志、备份目录
|
||||
RUN mkdir -p /app/data/backups /app/logs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# alembic 升级 + 启动 uvicorn
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
@@ -0,0 +1,43 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./data/backup.db
|
||||
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
timezone = UTC
|
||||
truncate_slug_length = 40
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Alembic env.py:从 app.config 读取 DATABASE_URL,使用 Base.metadata。"""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# 让 alembic 能 import app.*
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.database import Base # noqa: E402
|
||||
import app.models # noqa: F401, E402 # 触发所有模型注册
|
||||
|
||||
config = context.config
|
||||
|
||||
# 用 settings.DATABASE_URL 覆盖 alembic.ini 中的 url
|
||||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # SQLite 兼容
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True, # SQLite 兼容
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-06-22 00:00:00
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("username", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("is_admin", sa.Boolean, nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"storages",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("type", sa.String(16), nullable=False),
|
||||
sa.Column("config_json", sa.Text, nullable=False),
|
||||
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"jobs",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("type", sa.String(16), nullable=False),
|
||||
sa.Column("source_config_json", sa.Text, nullable=False),
|
||||
sa.Column("storage_id", sa.Integer, sa.ForeignKey("storages.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("cron_expression", sa.String(64), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("retention_count", sa.Integer, nullable=False, server_default=sa.text("7")),
|
||||
sa.Column("retention_days", sa.Integer, nullable=True),
|
||||
sa.Column("description", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"runs",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("job_id", sa.Integer, sa.ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="pending"),
|
||||
sa.Column("trigger", sa.String(16), nullable=False, server_default="manual"),
|
||||
sa.Column("started_at", sa.DateTime, nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime, nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer, nullable=True),
|
||||
sa.Column("artifact_path", sa.String(512), nullable=True),
|
||||
sa.Column("artifact_size", sa.BigInteger, nullable=True),
|
||||
sa.Column("artifact_checksum", sa.String(64), nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("log_excerpt", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_runs_job_id", "runs", ["job_id"])
|
||||
|
||||
op.create_table(
|
||||
"run_logs",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("run_id", sa.Integer, sa.ForeignKey("runs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_run_logs_run_id", "run_logs", ["run_id"])
|
||||
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.String(64), primary_key=True),
|
||||
sa.Column("value", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"restore_history",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("run_id", sa.Integer, sa.ForeignKey("runs.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("target", sa.String(512), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="pending"),
|
||||
sa.Column("started_at", sa.DateTime, nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_restore_history_run_id", "restore_history", ["run_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_restore_history_run_id", table_name="restore_history")
|
||||
op.drop_table("restore_history")
|
||||
op.drop_table("settings")
|
||||
op.drop_index("ix_run_logs_run_id", table_name="run_logs")
|
||||
op.drop_table("run_logs")
|
||||
op.drop_index("ix_runs_job_id", table_name="runs")
|
||||
op.drop_table("runs")
|
||||
op.drop_table("jobs")
|
||||
op.drop_table("storages")
|
||||
op.drop_index("ix_users_username", table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""认证 API。"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
from app.deps import CurrentUser, DBSession
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
TokenResponse,
|
||||
UserOut,
|
||||
)
|
||||
from app.security import create_access_token, hash_password, verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(payload: LoginRequest, db: DBSession) -> TokenResponse:
|
||||
user = db.query(User).filter(User.username == payload.username).first()
|
||||
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(user.username, extra={"uid": user.id, "adm": user.is_admin})
|
||||
return TokenResponse(access_token=token, expires_in=get_settings().JWT_EXPIRE_MINUTES * 60)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: CurrentUser) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(payload: ChangePasswordRequest, user: CurrentUser, db: DBSession) -> MessageResponse:
|
||||
if not verify_password(payload.old_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
return MessageResponse(message="Password updated")
|
||||
@@ -0,0 +1,159 @@
|
||||
"""备份任务 CRUD API。"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.executor import run_backup
|
||||
from app.deps import AdminUser, CurrentUser, DBSession
|
||||
from app.models.job import Job
|
||||
from app.models.run import Run
|
||||
from app.models.storage import Storage
|
||||
from app.schemas.job import (
|
||||
DirectorySourceConfig,
|
||||
JobCreate,
|
||||
JobOut,
|
||||
JobRunResponse,
|
||||
JobUpdate,
|
||||
JobWithLastRun,
|
||||
MySQLSourceConfig,
|
||||
RunSummary,
|
||||
)
|
||||
from app.scheduler.scheduler import remove_job, reschedule_job
|
||||
from app.utils.crypto import encrypt_dict
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
def _validate_source(type_: str, cfg: dict) -> dict:
|
||||
if type_ == "mysql":
|
||||
return MySQLSourceConfig(**cfg).model_dump()
|
||||
if type_ == "directory":
|
||||
return DirectorySourceConfig(**cfg).model_dump()
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported job type: {type_}")
|
||||
|
||||
|
||||
def _job_to_with_last_run(job: Job, db) -> JobWithLastRun:
|
||||
last = (
|
||||
db.query(Run)
|
||||
.filter(Run.job_id == job.id)
|
||||
.order_by(Run.id.desc())
|
||||
.first()
|
||||
)
|
||||
out = JobWithLastRun.model_validate(job)
|
||||
out.last_run = RunSummary.model_validate(last) if last else None
|
||||
return out
|
||||
|
||||
|
||||
@router.get("", response_model=list[JobWithLastRun])
|
||||
def list_jobs(db: DBSession, _: CurrentUser) -> list[JobWithLastRun]:
|
||||
jobs = db.query(Job).order_by(Job.id).all()
|
||||
return [_job_to_with_last_run(j, db) for j in jobs]
|
||||
|
||||
|
||||
@router.post("", response_model=JobOut, status_code=201)
|
||||
def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> Job:
|
||||
if db.query(Job).filter(Job.name == payload.name).first():
|
||||
raise HTTPException(status_code=400, detail="名称已存在")
|
||||
if not db.get(Storage, payload.storage_id):
|
||||
raise HTTPException(status_code=400, detail="存储目标不存在")
|
||||
src = _validate_source(payload.type, payload.source_config)
|
||||
job = Job(
|
||||
name=payload.name,
|
||||
type=payload.type,
|
||||
source_config_json=encrypt_dict(src),
|
||||
storage_id=payload.storage_id,
|
||||
cron_expression=payload.cron_expression,
|
||||
enabled=payload.enabled,
|
||||
retention_count=payload.retention_count,
|
||||
retention_days=payload.retention_days,
|
||||
description=payload.description,
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
reschedule_job(job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobWithLastRun)
|
||||
def get_job(job_id: int, db: DBSession, _: CurrentUser) -> JobWithLastRun:
|
||||
job = db.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return _job_to_with_last_run(job, db)
|
||||
|
||||
|
||||
@router.put("/{job_id}", response_model=JobOut)
|
||||
def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) -> Job:
|
||||
job = db.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if payload.name is not None:
|
||||
if db.query(Job).filter(Job.name == payload.name, Job.id != job_id).first():
|
||||
raise HTTPException(status_code=400, detail="名称已存在")
|
||||
job.name = payload.name
|
||||
if payload.source_config is not None:
|
||||
src = _validate_source(job.type, payload.source_config)
|
||||
job.source_config_json = encrypt_dict(src)
|
||||
if payload.storage_id is not None:
|
||||
if not db.get(Storage, payload.storage_id):
|
||||
raise HTTPException(status_code=400, detail="存储目标不存在")
|
||||
job.storage_id = payload.storage_id
|
||||
if payload.cron_expression is not None:
|
||||
job.cron_expression = payload.cron_expression or None
|
||||
if payload.enabled is not None:
|
||||
job.enabled = payload.enabled
|
||||
if payload.retention_count is not None:
|
||||
job.retention_count = payload.retention_count
|
||||
if payload.retention_days is not None:
|
||||
job.retention_days = payload.retention_days
|
||||
if payload.description is not None:
|
||||
job.description = payload.description
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
reschedule_job(job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.delete("/{job_id}", status_code=204)
|
||||
def delete_job(job_id: int, db: DBSession, _: AdminUser) -> None:
|
||||
job = db.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
remove_job(job.id)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{job_id}/run", response_model=JobRunResponse)
|
||||
async def run_job(job_id: int, db: DBSession, _: CurrentUser) -> JobRunResponse:
|
||||
job = db.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
run = Run(
|
||||
job_id=job.id,
|
||||
status="pending",
|
||||
trigger="manual",
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
run_id = run.id
|
||||
# 后台启动
|
||||
asyncio.create_task(run_backup(run_id, trigger="manual"))
|
||||
return JobRunResponse(run_id=run_id, status=run.status)
|
||||
|
||||
|
||||
@router.post("/{job_id}/toggle", response_model=JobOut)
|
||||
def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> Job:
|
||||
job = db.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
job.enabled = not job.enabled
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
reschedule_job(job.id)
|
||||
return job
|
||||
@@ -0,0 +1,76 @@
|
||||
"""恢复 API。"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.core.restore import execute_restore
|
||||
from app.deps import AdminUser, DBSession
|
||||
from app.models.job import Job
|
||||
from app.models.restore import RestoreHistory
|
||||
from app.models.run import Run
|
||||
from app.schemas.restore import RestoreOut, RestoreRequest, RestoreTargetsResponse
|
||||
from app.utils.crypto import decrypt_dict
|
||||
|
||||
router = APIRouter(prefix="/restore", tags=["restore"])
|
||||
|
||||
|
||||
@router.get("/targets", response_model=RestoreTargetsResponse)
|
||||
def get_restore_targets(run_id: int, db: DBSession, _: AdminUser) -> RestoreTargetsResponse:
|
||||
run = db.get(Run, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
job = db.get(Job, run.job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
src = decrypt_dict(job.source_config_json)
|
||||
suggested: list[str] = []
|
||||
if job.type == "mysql":
|
||||
suggested.append(src.get("database", ""))
|
||||
elif job.type == "directory":
|
||||
suggested.append(src.get("path", ""))
|
||||
|
||||
return RestoreTargetsResponse(
|
||||
run_id=run.id,
|
||||
job_type=job.type,
|
||||
artifact_size=run.artifact_size,
|
||||
artifact_checksum=run.artifact_checksum,
|
||||
suggested_targets=[s for s in suggested if s],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RestoreOut, status_code=201)
|
||||
async def trigger_restore(payload: RestoreRequest, db: DBSession, _: AdminUser) -> RestoreHistory:
|
||||
run = db.get(Run, payload.run_id)
|
||||
if not run or not run.artifact_path:
|
||||
raise HTTPException(status_code=404, detail="Run not found or no artifact")
|
||||
job = db.get(Job, run.job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# 校验 target_type 与 job.type 匹配
|
||||
if job.type == "mysql" and payload.target_type != "mysql_db":
|
||||
raise HTTPException(status_code=400, detail="target_type 与源类型不匹配(应为 mysql_db)")
|
||||
if job.type == "directory" and payload.target_type != "directory_path":
|
||||
raise HTTPException(status_code=400, detail="target_type 与源类型不匹配(应为 directory_path)")
|
||||
|
||||
rh = RestoreHistory(
|
||||
run_id=run.id,
|
||||
target=payload.target,
|
||||
status="pending",
|
||||
)
|
||||
db.add(rh)
|
||||
db.commit()
|
||||
db.refresh(rh)
|
||||
restore_id = rh.id
|
||||
asyncio.create_task(execute_restore(restore_id))
|
||||
return rh
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[RestoreOut])
|
||||
def restore_history(db: DBSession, _: AdminUser, run_id: int | None = None) -> list[RestoreHistory]:
|
||||
q = db.query(RestoreHistory)
|
||||
if run_id is not None:
|
||||
q = q.filter(RestoreHistory.run_id == run_id)
|
||||
return q.order_by(RestoreHistory.id.desc()).limit(200).all()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""API 总路由。"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import auth, jobs, restore, runs, storages
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(storages.router)
|
||||
api_router.include_router(jobs.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(restore.router)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""备份运行历史 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()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""存储目标 CRUD API。"""
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.storage.factory import create_storage as make_storage_impl
|
||||
from app.deps import AdminUser, DBSession
|
||||
from app.models.storage import Storage
|
||||
from app.schemas.storage import (
|
||||
LocalStorageConfig,
|
||||
S3StorageConfig,
|
||||
StorageCreate,
|
||||
StorageOut,
|
||||
StorageTestResponse,
|
||||
StorageUpdate,
|
||||
)
|
||||
from app.utils.crypto import decrypt_dict, encrypt_dict
|
||||
|
||||
router = APIRouter(prefix="/storages", tags=["storages"])
|
||||
|
||||
|
||||
def _validate_config(type_: str, config: dict) -> dict:
|
||||
if type_ == "local":
|
||||
return LocalStorageConfig(**config).model_dump()
|
||||
if type_ == "s3":
|
||||
return S3StorageConfig(**config).model_dump()
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported type: {type_}")
|
||||
|
||||
|
||||
@router.get("", response_model=list[StorageOut])
|
||||
def list_storages(db: DBSession, _: AdminUser) -> list[Storage]:
|
||||
return db.query(Storage).order_by(Storage.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=StorageOut, status_code=201)
|
||||
def create_storage_endpoint(payload: StorageCreate, db: DBSession, _: AdminUser) -> Storage:
|
||||
if db.query(Storage).filter(Storage.name == payload.name).first():
|
||||
raise HTTPException(status_code=400, detail="名称已存在")
|
||||
config = _validate_config(payload.type, payload.config)
|
||||
row = Storage(
|
||||
name=payload.name,
|
||||
type=payload.type,
|
||||
config_json=encrypt_dict(config),
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
if payload.is_default:
|
||||
db.query(Storage).update({Storage.is_default: False})
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/{storage_id}", response_model=StorageOut)
|
||||
def get_storage(storage_id: int, db: DBSession, _: AdminUser) -> Storage:
|
||||
row = db.get(Storage, storage_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.put("/{storage_id}", response_model=StorageOut)
|
||||
def update_storage(storage_id: int, payload: StorageUpdate, db: DBSession, _: AdminUser) -> Storage:
|
||||
row = db.get(Storage, storage_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if payload.name is not None:
|
||||
if db.query(Storage).filter(Storage.name == payload.name, Storage.id != storage_id).first():
|
||||
raise HTTPException(status_code=400, detail="名称已存在")
|
||||
row.name = payload.name
|
||||
if payload.config is not None:
|
||||
config = _validate_config(row.type, payload.config)
|
||||
row.config_json = encrypt_dict(config)
|
||||
if payload.is_default is not None:
|
||||
if payload.is_default:
|
||||
db.query(Storage).update({Storage.is_default: False})
|
||||
row.is_default = payload.is_default
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/{storage_id}", status_code=204)
|
||||
def delete_storage(storage_id: int, db: DBSession, _: AdminUser) -> None:
|
||||
row = db.get(Storage, storage_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{storage_id}/test", response_model=StorageTestResponse)
|
||||
async def test_storage(storage_id: int, db: DBSession, _: AdminUser) -> StorageTestResponse:
|
||||
row = db.get(Storage, storage_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
storage = make_storage_impl(row)
|
||||
ok, msg = await storage.test_connection()
|
||||
return StorageTestResponse(ok=ok, message=msg)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""应用配置:从环境变量加载,启动时校验必填项。"""
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ===== 必填 =====
|
||||
SECRET_KEY: str = Field(..., description="JWT 签名密钥(>= 32 字符)")
|
||||
FERNET_KEY: str = Field(..., description="Fernet 字段加密密钥")
|
||||
INITIAL_ADMIN_PASSWORD: str = Field(..., min_length=8)
|
||||
|
||||
# ===== 可选 =====
|
||||
JWT_EXPIRE_MINUTES: int = 1440
|
||||
DATABASE_URL: str = "sqlite:///./data/backup.db"
|
||||
STORAGE_LOCAL_ROOT: str = "./data/backups"
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:8000"
|
||||
TZ: str = "UTC"
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
if len(v) < 32:
|
||||
raise ValueError("SECRET_KEY 长度必须 >= 32 字符(建议 openssl rand -hex 32)")
|
||||
return v
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""备份执行抽象基类:MySQL / Directory 都实现此接口。"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
"""备份产物的元信息。"""
|
||||
|
||||
suggested_filename: str # e.g. "db-20260622-030000.sql.gz" 或 "logs-20260622.tar.gz"
|
||||
content_type: str = "application/octet-stream"
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
class BaseBackup(ABC):
|
||||
"""所有数据源类型实现此接口。
|
||||
|
||||
方法职责:
|
||||
- metadata(): 计算建议文件名等
|
||||
- produce(): 以异步字节流形式产出备份内容
|
||||
"""
|
||||
|
||||
def __init__(self, source_config: dict, job_name: str):
|
||||
self.source_config = source_config
|
||||
self.job_name = job_name
|
||||
|
||||
@abstractmethod
|
||||
def metadata(self) -> BackupMetadata: ...
|
||||
|
||||
@abstractmethod
|
||||
async def produce(self) -> AsyncIterator[bytes]:
|
||||
"""异步产出备份字节流。"""
|
||||
# 子类应使用 async with + async generator
|
||||
...
|
||||
yield b"" # pragma: no cover
|
||||
@@ -0,0 +1,127 @@
|
||||
"""目录备份:使用 tarfile 流式打包为 tar.gz。"""
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.core.backup.base import BackupMetadata, BaseBackup
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class DirectoryBackup(BaseBackup):
|
||||
def __init__(self, source_config: dict, job_name: str):
|
||||
super().__init__(source_config, job_name)
|
||||
self.path: str = source_config["path"]
|
||||
self.exclude_patterns: list[str] = source_config.get("exclude_patterns", []) or []
|
||||
self.base_dir = os.path.basename(os.path.normpath(self.path)) or "root"
|
||||
self.root_path = Path(self.path).resolve()
|
||||
|
||||
def metadata(self) -> BackupMetadata:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
safe = "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in self.base_dir)
|
||||
return BackupMetadata(
|
||||
suggested_filename=f"{self.job_name}-{safe}-{ts}.tar.gz",
|
||||
content_type="application/gzip",
|
||||
extra={"path": self.path, "base_dir": self.base_dir},
|
||||
)
|
||||
|
||||
def _is_excluded(self, rel_path: str) -> bool:
|
||||
name = os.path.basename(rel_path)
|
||||
for pat in self.exclude_patterns:
|
||||
if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel_path, pat):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def produce(self) -> AsyncIterator[bytes]:
|
||||
if not self.root_path.exists():
|
||||
raise FileNotFoundError(f"Source path does not exist: {self.path}")
|
||||
if not self.root_path.is_dir():
|
||||
raise NotADirectoryError(f"Source path is not a directory: {self.path}")
|
||||
|
||||
log.info("Taring directory %s (exclude=%s)", self.path, self.exclude_patterns)
|
||||
|
||||
# 用一个 Queue 在同步线程和异步消费者之间桥接 tar 产出
|
||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=32)
|
||||
|
||||
def _tar_worker() -> None:
|
||||
import tarfile
|
||||
|
||||
try:
|
||||
with tarfile.open(mode="w:gz", bufsize=1024 * 1024) as tar:
|
||||
# 顶层目录名固定为 base_dir,便于解压后还原
|
||||
for dirpath, dirnames, filenames in os.walk(self.root_path):
|
||||
# 排除目录
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not self._is_excluded(os.path.relpath(os.path.join(dirpath, d), self.root_path))
|
||||
]
|
||||
for fn in filenames:
|
||||
abs_path = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(abs_path, self.root_path)
|
||||
if self._is_excluded(rel):
|
||||
continue
|
||||
try:
|
||||
tar.add(abs_path, arcname=os.path.join(self.base_dir, rel), recursive=False)
|
||||
except (PermissionError, OSError) as e:
|
||||
log.warning("Skip %s: %s", abs_path, e)
|
||||
continue
|
||||
# 周期性 flush 到 queue
|
||||
if tar.fileobj.tell() and (tar.fileobj.tell() % (256 * 1024) == 0):
|
||||
# 这里我们用 tarfile 的 fileobj 直接喂到 queue 更高效
|
||||
pass
|
||||
except Exception as e:
|
||||
log.exception("tar worker error: %s", e)
|
||||
finally:
|
||||
# tarfile 关闭后会写完 gzip trailer,我们需要在关闭时把 fileobj 全部送出
|
||||
pass
|
||||
|
||||
# 实现简化:使用 tarfile 的 fileobj=BytesIO 会在循环中累积,
|
||||
# 对超大目录会爆内存。下面用一个临时文件 + 异步读取的方案。
|
||||
import tempfile
|
||||
|
||||
tmp_path = Path(tempfile.gettempdir()) / f"backup_{self.job_name}_{os.getpid()}.tar.gz"
|
||||
|
||||
def _tar_to_file() -> None:
|
||||
import tarfile
|
||||
|
||||
try:
|
||||
with tarfile.open(str(tmp_path), mode="w:gz") as tar:
|
||||
for dirpath, dirnames, filenames in os.walk(self.root_path):
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not self._is_excluded(os.path.relpath(os.path.join(dirpath, d), self.root_path))
|
||||
]
|
||||
for fn in filenames:
|
||||
abs_path = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(abs_path, self.root_path)
|
||||
if self._is_excluded(rel):
|
||||
continue
|
||||
try:
|
||||
tar.add(abs_path, arcname=os.path.join(self.base_dir, rel), recursive=False)
|
||||
except (PermissionError, OSError) as e:
|
||||
log.warning("Skip %s: %s", abs_path, e)
|
||||
except Exception:
|
||||
log.exception("tar file worker failed")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _tar_to_file)
|
||||
|
||||
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"Failed to create tar.gz for {self.path}")
|
||||
|
||||
try:
|
||||
with open(tmp_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await loop.run_in_executor(None, f.read, 64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,97 @@
|
||||
"""MySQL 备份:通过 mysqldump 子进程流式输出。"""
|
||||
import asyncio
|
||||
import gzip
|
||||
import shlex
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.core.backup.base import BackupMetadata, BaseBackup
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
CHUNK_SIZE = 64 * 1024
|
||||
|
||||
|
||||
class MySQLBackup(BaseBackup):
|
||||
def __init__(self, source_config: dict, job_name: str):
|
||||
super().__init__(source_config, job_name)
|
||||
self.host: str = source_config["host"]
|
||||
self.port: int = int(source_config.get("port", 3306))
|
||||
self.user: str = source_config["user"]
|
||||
self.password: str = source_config.get("password", "")
|
||||
self.database: str = source_config["database"]
|
||||
self.extra_args: str = source_config.get("extra_args", "")
|
||||
|
||||
def metadata(self) -> BackupMetadata:
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
safe_db = "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in self.database)
|
||||
return BackupMetadata(
|
||||
suggested_filename=f"{self.job_name}-{safe_db}-{ts}.sql.gz",
|
||||
content_type="application/gzip",
|
||||
extra={"database": self.database, "host": self.host, "port": self.port},
|
||||
)
|
||||
|
||||
async def produce(self) -> AsyncIterator[bytes]:
|
||||
args = [
|
||||
"mysqldump",
|
||||
"-h", self.host,
|
||||
"-P", str(self.port),
|
||||
"-u", self.user,
|
||||
"--single-transaction",
|
||||
"--quick",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--events",
|
||||
"--default-character-set=utf8mb4",
|
||||
]
|
||||
if self.extra_args:
|
||||
args.extend(shlex.split(self.extra_args))
|
||||
args.append(self.database)
|
||||
|
||||
log.info("Executing mysqldump for db=%s host=%s", self.database, self.host)
|
||||
|
||||
# mysqldump 直接写 stdout,我们 pipe 给 gzip 再 yield
|
||||
# 用 -p 紧贴密码避免出现在 ps 里(空密码时 mysqldump 会要求交互输入)
|
||||
if self.password:
|
||||
args_with_pwd = args.copy()
|
||||
# 找到 -u 后插入 -pXXX
|
||||
u_idx = args_with_pwd.index("-u")
|
||||
args_with_pwd.insert(u_idx + 2, f"-p{self.password}")
|
||||
else:
|
||||
args_with_pwd = args
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args_with_pwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
assert proc.stdout is not None
|
||||
|
||||
# 在线程里跑 gzip.compress,因为它是阻塞的
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def gzip_stream() -> AsyncIterator[bytes]:
|
||||
chunk_buf = bytearray()
|
||||
async for chunk in proc.stdout: # type: ignore[union-attr]
|
||||
# gzip 接受 bytes,返回 bytes
|
||||
compressed = await loop.run_in_executor(None, gzip.compress, chunk)
|
||||
if compressed:
|
||||
yield compressed
|
||||
|
||||
try:
|
||||
async for piece in gzip_stream():
|
||||
yield piece
|
||||
finally:
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode != 0:
|
||||
stderr = (await proc.stderr.read()).decode(errors="replace") if proc.stderr else ""
|
||||
raise RuntimeError(
|
||||
f"mysqldump failed (exit={proc.returncode}): {stderr.strip()[:2000]}"
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""备份执行编排:解密配置 → 流式产出 → 流式上传 → 应用保留策略 → 写日志。"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.backup.base import BaseBackup
|
||||
from app.core.backup.directory import DirectoryBackup
|
||||
from app.core.backup.mysql import MySQLBackup
|
||||
from app.core.retention import apply_retention
|
||||
from app.core.storage.factory import create_storage
|
||||
from app.database import SessionLocal
|
||||
from app.models.job import Job
|
||||
from app.models.run import Run, RunLog
|
||||
from app.utils.crypto import decrypt_dict
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def _make_backup(job: Job) -> BaseBackup:
|
||||
src = decrypt_dict(job.source_config_json)
|
||||
if job.type == "mysql":
|
||||
return MySQLBackup(src, job.name)
|
||||
if job.type == "directory":
|
||||
return DirectoryBackup(src, job.name)
|
||||
raise ValueError(f"Unsupported job type: {job.type}")
|
||||
|
||||
|
||||
async def run_backup(run_id: int, trigger: str = "manual") -> None:
|
||||
"""执行一次备份。所有副作用都在自己 new 的 Session 上完成。"""
|
||||
started = datetime.utcnow()
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
run: Run | None = db.get(Run, run_id)
|
||||
if not run:
|
||||
log.error("run_id=%s not found", run_id)
|
||||
return
|
||||
|
||||
job: Job | None = db.get(Job, run.job_id)
|
||||
if not job:
|
||||
run.status = "failed"
|
||||
run.error_message = f"Job id={run.job_id} disappeared"
|
||||
run.finished_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return
|
||||
|
||||
log_buffer = io.StringIO()
|
||||
tee = _TeeLogger(log, log_buffer)
|
||||
|
||||
run.status = "running"
|
||||
run.trigger = trigger
|
||||
run.started_at = started
|
||||
db.commit()
|
||||
|
||||
tee.info("Backup started: run_id=%s job=%s trigger=%s", run_id, job.name, trigger)
|
||||
|
||||
try:
|
||||
backup = _make_backup(job)
|
||||
meta = backup.metadata()
|
||||
tee.info("Metadata: filename=%s content_type=%s", meta.suggested_filename, meta.content_type)
|
||||
|
||||
storage = create_storage(job.storage)
|
||||
ok, msg = await storage.test_connection()
|
||||
tee.info("Storage test: ok=%s msg=%s", ok, msg)
|
||||
if not ok:
|
||||
raise RuntimeError(f"Storage not reachable: {msg}")
|
||||
|
||||
# 关键路径:备份文件名以 {job_name}/{YYYY-MM-DD}/{filename} 组织
|
||||
date_prefix = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
object_key = f"{job.name}/{date_prefix}/{meta.suggested_filename}"
|
||||
|
||||
tee.info("Streaming backup to storage key=%s ...", object_key)
|
||||
|
||||
async def _consume_with_progress():
|
||||
async for chunk in backup.produce():
|
||||
yield chunk
|
||||
|
||||
t0 = time.monotonic()
|
||||
size, sha = await storage.upload_stream(
|
||||
_consume_with_progress(),
|
||||
object_key,
|
||||
content_type=meta.content_type,
|
||||
)
|
||||
elapsed = int(time.monotonic() - t0)
|
||||
|
||||
run.artifact_path = object_key
|
||||
run.artifact_size = size
|
||||
run.artifact_checksum = sha
|
||||
run.duration_seconds = elapsed
|
||||
run.status = "success"
|
||||
run.finished_at = datetime.utcnow()
|
||||
tee.info(
|
||||
"Backup success: size=%s bytes sha256=%s elapsed=%ss",
|
||||
size, sha[:12], elapsed,
|
||||
)
|
||||
|
||||
# 应用保留策略
|
||||
try:
|
||||
removed = apply_retention(db, job)
|
||||
if removed:
|
||||
tee.info("Retention cleanup removed %s old runs", removed)
|
||||
except Exception as e:
|
||||
tee.warning("Retention cleanup failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Backup run_id=%s failed", run_id)
|
||||
tee.error("Backup failed: %s: %s", type(e).__name__, e)
|
||||
run.status = "failed"
|
||||
run.error_message = f"{type(e).__name__}: {e}"[:2000]
|
||||
run.finished_at = datetime.utcnow()
|
||||
finally:
|
||||
log_text = log_buffer.getvalue()
|
||||
run.log_excerpt = log_text[-4000:] if len(log_text) > 4000 else log_text
|
||||
run_log = RunLog(run_id=run.id, content=log_text)
|
||||
db.add(run_log)
|
||||
db.commit()
|
||||
log.info("Backup run_id=%s finished status=%s", run_id, run.status)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class _TeeLogger:
|
||||
"""同时写标准 logging 和内存 StringIO(用于存数据库)。"""
|
||||
|
||||
def __init__(self, real: logging.Logger, buf: io.StringIO):
|
||||
self._real = real
|
||||
self._buf = buf
|
||||
|
||||
def _emit(self, level: int, msg: str) -> None:
|
||||
ts = datetime.utcnow().strftime("%H:%M:%S")
|
||||
line = f"[{ts}] {msg}"
|
||||
self._real.log(level, line)
|
||||
self._buf.write(line + "\n")
|
||||
|
||||
def info(self, msg: str, *args) -> None:
|
||||
self._emit(logging.INFO, msg % args if args else msg)
|
||||
|
||||
def warning(self, msg: str, *args) -> None:
|
||||
self._emit(logging.WARNING, msg % args if args else msg)
|
||||
|
||||
def error(self, msg: str, *args) -> None:
|
||||
self._emit(logging.ERROR, msg % args if args else msg)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""恢复执行器:把 backup artifact 还原到目标(MySQL 库 / 目录路径)。"""
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.storage.factory import create_storage
|
||||
from app.database import SessionLocal
|
||||
from app.models.job import Job
|
||||
from app.models.restore import RestoreHistory
|
||||
from app.models.run import Run, RunLog
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def execute_restore(restore_id: int) -> None:
|
||||
"""执行一次恢复。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rh: RestoreHistory | None = db.get(RestoreHistory, restore_id)
|
||||
if not rh:
|
||||
return
|
||||
|
||||
rh.status = "running"
|
||||
rh.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
run: Run | None = db.get(Run, rh.run_id)
|
||||
if not run or not run.artifact_path:
|
||||
raise RuntimeError("源 run 或 artifact 不存在")
|
||||
job: Job | None = db.get(Job, run.job_id)
|
||||
if not job:
|
||||
raise RuntimeError("源 job 不存在")
|
||||
|
||||
storage = create_storage(job.storage)
|
||||
log_text = io.StringIO()
|
||||
|
||||
def logl(msg: str) -> None:
|
||||
line = f"[{datetime.utcnow():%H:%M:%S}] {msg}"
|
||||
log.info(line)
|
||||
log_text.write(line + "\n")
|
||||
|
||||
try:
|
||||
logl(f"Restoring run_id={run.id} -> {rh.target} (type={job.type})")
|
||||
|
||||
# 先下载到临时文件
|
||||
tmpdir = Path(tempfile.gettempdir()) / f"restore_{rh.id}_{os.getpid()}"
|
||||
tmpdir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = tmpdir / Path(run.artifact_path).name
|
||||
|
||||
logl(f"Downloading {run.artifact_path} -> {local_tmp}")
|
||||
size = 0
|
||||
with open(local_tmp, "wb") as f:
|
||||
async for chunk in storage.download_stream(run.artifact_path):
|
||||
f.write(chunk)
|
||||
size += len(chunk)
|
||||
logl(f"Downloaded {size} bytes")
|
||||
|
||||
if job.type == "mysql":
|
||||
await _restore_mysql(local_tmp, rh.target, job, logl)
|
||||
elif job.type == "directory":
|
||||
_restore_directory(local_tmp, rh.target, logl)
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported job type: {job.type}")
|
||||
|
||||
rh.status = "success"
|
||||
rh.finished_at = datetime.utcnow()
|
||||
logl("Restore success")
|
||||
except Exception as e:
|
||||
log.exception("Restore failed")
|
||||
logl(f"Restore failed: {type(e).__name__}: {e}")
|
||||
rh.status = "failed"
|
||||
rh.error_message = f"{type(e).__name__}: {e}"[:2000]
|
||||
rh.finished_at = datetime.utcnow()
|
||||
finally:
|
||||
# 写日志
|
||||
content = log_text.getvalue()
|
||||
db.add(RunLog(run_id=run.id, content=f"[restore #{rh.id}]\n{content}"))
|
||||
db.commit()
|
||||
try:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _restore_mysql(archive_path: Path, target_db: str, job: Job, logl) -> None:
|
||||
"""把 sql.gz 通过 gunzip | mysql 还原到 target_db。"""
|
||||
from app.utils.crypto import decrypt_dict
|
||||
|
||||
src = decrypt_dict(job.source_config_json)
|
||||
host = src["host"]
|
||||
port = int(src.get("port", 3306))
|
||||
user = src["user"]
|
||||
password = src.get("password", "")
|
||||
|
||||
logl(f"Decompressing and piping to mysql db={target_db} host={host}:{port}")
|
||||
# 解压
|
||||
decompressed = archive_path.with_suffix("") # .sql
|
||||
if archive_path.name.endswith(".gz"):
|
||||
with gzip.open(archive_path, "rb") as gz, open(decompressed, "wb") as out:
|
||||
shutil.copyfileobj(gz, out)
|
||||
sql_file = decompressed
|
||||
else:
|
||||
sql_file = archive_path
|
||||
|
||||
# 调用 mysql client
|
||||
args = ["mysql", "-h", host, "-P", str(port), "-u", user, target_db]
|
||||
if password:
|
||||
# 在 -u 后插入 -pPWD
|
||||
idx = args.index("-u")
|
||||
args.insert(idx + 2, f"-p{password}")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
assert proc.stdin is not None
|
||||
with open(sql_file, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
proc.stdin.write(chunk)
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
rc = await proc.wait()
|
||||
if rc != 0:
|
||||
stderr = (await proc.stderr.read()).decode(errors="replace") if proc.stderr else ""
|
||||
raise RuntimeError(f"mysql restore failed (exit={rc}): {stderr[:2000]}")
|
||||
try:
|
||||
decompressed.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_directory(archive_path: Path, target_dir: str, logl) -> None:
|
||||
"""解压 tar.gz 到 target_dir(必须为空或不存在)。"""
|
||||
target = Path(target_dir).resolve()
|
||||
if target.exists() and any(target.iterdir()):
|
||||
raise RuntimeError(f"目标目录非空: {target}")
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logl(f"Extracting tar.gz to {target}")
|
||||
if archive_path.name.endswith(".gz"):
|
||||
mode = "r:gz"
|
||||
else:
|
||||
mode = "r:"
|
||||
with tarfile.open(str(archive_path), mode) as tar:
|
||||
# 安全检查:拒绝路径逃逸
|
||||
for member in tar.getmembers():
|
||||
member_path = (target / member.name).resolve()
|
||||
if not str(member_path).startswith(str(target)):
|
||||
raise RuntimeError(f"Refusing to extract unsafe path: {member.name}")
|
||||
tar.extractall(path=str(target))
|
||||
@@ -0,0 +1,80 @@
|
||||
"""备份保留策略清理。"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage.factory import create_storage
|
||||
from app.models.job import Job
|
||||
from app.models.run import Run
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def apply_retention(db: Session, job: Job) -> int:
|
||||
"""根据 job 的 retention_count / retention_days 清理旧备份。
|
||||
|
||||
返回删除的 run 数。
|
||||
"""
|
||||
# 取该 job 所有成功 runs,按 finished_at 倒序
|
||||
runs = (
|
||||
db.query(Run)
|
||||
.filter(Run.job_id == job.id, Run.status == "success")
|
||||
.order_by(Run.finished_at.desc())
|
||||
.all()
|
||||
)
|
||||
if not runs:
|
||||
return 0
|
||||
|
||||
keep_count = job.retention_count or 7
|
||||
keep_days = job.retention_days
|
||||
|
||||
to_keep_ids: set[int] = set()
|
||||
for i, r in enumerate(runs):
|
||||
keep_by_count = i < keep_count
|
||||
keep_by_days = True
|
||||
if keep_days and r.finished_at:
|
||||
threshold = datetime.utcnow() - timedelta(days=keep_days)
|
||||
if r.finished_at < threshold:
|
||||
keep_by_days = False
|
||||
if keep_by_count or keep_by_days:
|
||||
to_keep_ids.add(r.id)
|
||||
|
||||
to_delete = [r for r in runs if r.id not in to_keep_ids]
|
||||
if not to_delete:
|
||||
return 0
|
||||
|
||||
storage = create_storage(job.storage)
|
||||
removed = 0
|
||||
for r in to_delete:
|
||||
try:
|
||||
if r.artifact_path:
|
||||
# 用同步方法简单调用即可(local 是同步包装 async,但这里 aioboto3/aiofiles 都是异步;
|
||||
# 实际上 base storage 都是 async 方法,所以这里需要在同步上下文跑 event loop)
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("Failed to delete artifact %s (db row still kept)", r.artifact_path)
|
||||
db.delete(r)
|
||||
removed += 1
|
||||
|
||||
db.commit()
|
||||
log.info("Retention cleanup for job=%s: deleted %s runs", job.name, removed)
|
||||
|
||||
# 真实删除 S3/local 文件(需要在 async 上下文)
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
for r in to_delete:
|
||||
if r.artifact_path:
|
||||
try:
|
||||
loop.run_until_complete(storage.delete(r.artifact_path))
|
||||
except Exception:
|
||||
log.exception("Artifact delete failed: %s", r.artifact_path)
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception:
|
||||
log.exception("Artifact cleanup loop error")
|
||||
|
||||
return removed
|
||||
@@ -0,0 +1,45 @@
|
||||
"""存储后端抽象接口。"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageObject:
|
||||
key: str
|
||||
size: int
|
||||
last_modified: Optional[str] = None # ISO 8601
|
||||
|
||||
|
||||
class BaseStorage(ABC):
|
||||
"""所有存储后端实现的统一接口。"""
|
||||
|
||||
type: str = "base"
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
"""测试连通性。返回 (ok, message)。"""
|
||||
|
||||
@abstractmethod
|
||||
async def upload_stream(
|
||||
self,
|
||||
source: AsyncIterator[bytes],
|
||||
key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> tuple[int, str]:
|
||||
"""流式上传。返回 (size_bytes, sha256_hex)。"""
|
||||
|
||||
@abstractmethod
|
||||
async def download_stream(self, key: str) -> AsyncIterator[bytes]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, key: str) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list(self, prefix: str = "") -> list[StorageObject]: ...
|
||||
@@ -0,0 +1,15 @@
|
||||
"""存储工厂:根据 storage 行创建对应实现。"""
|
||||
from app.core.storage.base import BaseStorage
|
||||
from app.core.storage.local import LocalStorage
|
||||
from app.core.storage.s3 import S3Storage
|
||||
from app.models.storage import Storage
|
||||
from app.utils.crypto import decrypt_dict
|
||||
|
||||
|
||||
def create_storage(row: Storage) -> BaseStorage:
|
||||
config = decrypt_dict(row.config_json)
|
||||
if row.type == "local":
|
||||
return LocalStorage(config)
|
||||
if row.type == "s3":
|
||||
return S3Storage(config)
|
||||
raise ValueError(f"Unsupported storage type: {row.type}")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""本地文件系统存储。"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import aiofiles
|
||||
|
||||
from app.core.storage.base import BaseStorage, StorageObject
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class LocalStorage(BaseStorage):
|
||||
type = "local"
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.root: str = os.path.abspath(config["path"])
|
||||
Path(self.root).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _resolve(self, key: str) -> Path:
|
||||
# 防 path traversal:禁止 ..
|
||||
if ".." in key.split("/"):
|
||||
raise ValueError(f"Invalid key (path traversal): {key}")
|
||||
return Path(self.root) / key
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
Path(self.root).mkdir(parents=True, exist_ok=True)
|
||||
test_file = Path(self.root) / ".backup_system_test"
|
||||
async with aiofiles.open(test_file, "w") as f:
|
||||
await f.write("ok")
|
||||
# aiofiles 不暴露 os 命名空间,用线程池跑同步删除
|
||||
await asyncio.to_thread(os.remove, test_file)
|
||||
return True, f"OK: writable {self.root}"
|
||||
except Exception as e:
|
||||
return False, f"FAIL: {e}"
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
source: AsyncIterator[bytes],
|
||||
key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> tuple[int, str]:
|
||||
path = self._resolve(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sha = hashlib.sha256()
|
||||
size = 0
|
||||
async with aiofiles.open(path, "wb") as f:
|
||||
async for chunk in source:
|
||||
await f.write(chunk)
|
||||
sha.update(chunk)
|
||||
size += len(chunk)
|
||||
return size, sha.hexdigest()
|
||||
|
||||
async def download_stream(self, key: str) -> AsyncIterator[bytes]:
|
||||
path = self._resolve(key)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Not found: {key}")
|
||||
async with aiofiles.open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
path = self._resolve(key)
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
return self._resolve(key).exists()
|
||||
|
||||
async def list(self, prefix: str = "") -> list[StorageObject]:
|
||||
base = Path(self.root) / prefix if prefix else Path(self.root)
|
||||
if not base.exists():
|
||||
return []
|
||||
out: list[StorageObject] = []
|
||||
for p in base.rglob("*"):
|
||||
if p.is_file():
|
||||
rel = p.relative_to(Path(self.root)).as_posix()
|
||||
st = p.stat()
|
||||
out.append(
|
||||
StorageObject(
|
||||
key=rel,
|
||||
size=st.st_size,
|
||||
last_modified=datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,176 @@
|
||||
"""S3 兼容对象存储(支持 MinIO / Ceph / AWS S3)。"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import aioboto3
|
||||
|
||||
from app.core.storage.base import BaseStorage, StorageObject
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class S3Storage(BaseStorage):
|
||||
type = "s3"
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
self.endpoint_url: str | None = config.get("endpoint_url") or None
|
||||
self.region: str = config.get("region") or "us-east-1"
|
||||
self.bucket: str = config["bucket"]
|
||||
self.access_key: str = config["access_key"]
|
||||
self.secret_key: str = config["secret_key"]
|
||||
self.use_ssl: bool = bool(config.get("use_ssl", True))
|
||||
self.path_prefix: str = config.get("path_prefix", "").strip("/")
|
||||
self.addressing_style: str = config.get("addressing_style", "auto")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(self):
|
||||
session = aioboto3.Session()
|
||||
async with session.client(
|
||||
"s3",
|
||||
endpoint_url=self.endpoint_url,
|
||||
region_name=self.region,
|
||||
aws_access_key_id=self.access_key,
|
||||
aws_secret_access_key=self.secret_key,
|
||||
use_ssl=self.use_ssl,
|
||||
config=__import__("botocore.config", fromlist=["Config"]).Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": self.addressing_style},
|
||||
),
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
def _full_key(self, key: str) -> str:
|
||||
key = key.lstrip("/")
|
||||
if self.path_prefix:
|
||||
return f"{self.path_prefix}/{key}"
|
||||
return key
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
async with self._client() as client:
|
||||
await client.head_bucket(Bucket=self.bucket)
|
||||
return True, f"OK: bucket={self.bucket}"
|
||||
except Exception as e:
|
||||
return False, f"FAIL: {type(e).__name__}: {e}"
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
source: AsyncIterator[bytes],
|
||||
key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> tuple[int, str]:
|
||||
sha = hashlib.sha256()
|
||||
size = 0
|
||||
full_key = self._full_key(key)
|
||||
|
||||
async with self._client() as client:
|
||||
# 用 UploadPart + 内存缓冲整个对象再 put_object(对中小备份足够)
|
||||
# 大文件可改为 multipart;这里用 MultipartUploader 模式,兼顾大文件
|
||||
from io import BytesIO
|
||||
|
||||
buffer = BytesIO()
|
||||
async for chunk in source:
|
||||
buffer.write(chunk)
|
||||
sha.update(chunk)
|
||||
size += len(chunk)
|
||||
|
||||
# 5MB 阈值:超过用 multipart
|
||||
PART_SIZE = 5 * 1024 * 1024
|
||||
if size <= PART_SIZE:
|
||||
buffer.seek(0)
|
||||
await client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=full_key,
|
||||
Body=buffer.getvalue(),
|
||||
ContentType=content_type,
|
||||
)
|
||||
else:
|
||||
# multipart
|
||||
mpu = await client.create_multipart_upload(
|
||||
Bucket=self.bucket, Key=full_key, ContentType=content_type
|
||||
)
|
||||
parts: list[dict[str, Any]] = []
|
||||
try:
|
||||
buffer.seek(0)
|
||||
part_number = 1
|
||||
while True:
|
||||
data = buffer.read(PART_SIZE)
|
||||
if not data:
|
||||
break
|
||||
resp = await client.upload_part(
|
||||
Bucket=self.bucket,
|
||||
Key=full_key,
|
||||
PartNumber=part_number,
|
||||
UploadId=mpu["UploadId"],
|
||||
Body=data,
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": resp["ETag"]})
|
||||
part_number += 1
|
||||
await client.complete_multipart_upload(
|
||||
Bucket=self.bucket,
|
||||
Key=full_key,
|
||||
UploadId=mpu["UploadId"],
|
||||
MultipartUpload={"Parts": parts},
|
||||
)
|
||||
except Exception:
|
||||
await client.abort_multipart_upload(
|
||||
Bucket=self.bucket, Key=full_key, UploadId=mpu["UploadId"]
|
||||
)
|
||||
raise
|
||||
|
||||
return size, sha.hexdigest()
|
||||
|
||||
async def download_stream(self, key: str) -> AsyncIterator[bytes]:
|
||||
full_key = self._full_key(key)
|
||||
async with self._client() as client:
|
||||
obj = await client.get_object(Bucket=self.bucket, Key=full_key)
|
||||
body = obj["Body"]
|
||||
try:
|
||||
while True:
|
||||
chunk = await asyncio.get_event_loop().run_in_executor(
|
||||
None, body.read, 64 * 1024
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
body.close()
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
full_key = self._full_key(key)
|
||||
async with self._client() as client:
|
||||
await client.delete_object(Bucket=self.bucket, Key=full_key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
full_key = self._full_key(key)
|
||||
async with self._client() as client:
|
||||
try:
|
||||
await client.head_object(Bucket=self.bucket, Key=full_key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def list(self, prefix: str = "") -> list[StorageObject]:
|
||||
full_prefix = self._full_key(prefix)
|
||||
out: list[StorageObject] = []
|
||||
async with self._client() as client:
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
async for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
if self.path_prefix and key.startswith(self.path_prefix + "/"):
|
||||
key = key[len(self.path_prefix) + 1 :]
|
||||
last_modified = obj.get("LastModified")
|
||||
out.append(
|
||||
StorageObject(
|
||||
key=key,
|
||||
size=int(obj.get("Size", 0)),
|
||||
last_modified=last_modified.isoformat() if last_modified else None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,59 @@
|
||||
"""SQLAlchemy 引擎与 Session 工厂。"""
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# SQLite 需要 check_same_thread=False(虽然我们用 FastAPI async/threadpool,但仍建议)
|
||||
connect_args = {}
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args=connect_args,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
expire_on_commit=False,
|
||||
class_=Session,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有 ORM 模型的基类。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
"""FastAPI 依赖:每次请求一个 Session。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""普通上下文管理器版本,供非请求路径使用(如初始化、调度器)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""FastAPI 依赖注入:当前用户、DB Session 等。"""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.security import decode_token
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
DBSession = Annotated[Session, Depends(get_db)]
|
||||
TokenDep = Annotated[str | None, Depends(oauth2_scheme)]
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: TokenDep,
|
||||
db: DBSession,
|
||||
) -> User:
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
username: str | None = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_admin(user: CurrentUser) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin privilege required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""FastAPI 应用入口:lifespan 管理 scheduler、首次启动创建 admin。"""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.config import get_settings
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.models.user import User
|
||||
from app.scheduler.scheduler import scheduler_lifespan
|
||||
from app.security import hash_password
|
||||
from app.utils.logging import setup_logging
|
||||
|
||||
setup_logging()
|
||||
log = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def init_database() -> None:
|
||||
"""建表(Alembic 是首选;这里兜底确保首次启动也能跑)。"""
|
||||
try:
|
||||
# 实际生产用 alembic upgrade head;这里仅当表不存在时创建
|
||||
Base.metadata.create_all(bind=engine)
|
||||
log.info("Database tables ensured")
|
||||
except OperationalError as e:
|
||||
log.error("Database init failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def init_admin() -> None:
|
||||
"""首次启动创建 admin 账号(仅当 users 表为空时)。"""
|
||||
with SessionLocal() as db:
|
||||
try:
|
||||
existing = db.query(User).count()
|
||||
if existing > 0:
|
||||
log.info("Existing users found (%d), skip admin init", existing)
|
||||
return
|
||||
admin = User(
|
||||
username="admin",
|
||||
password_hash=hash_password(settings.INITIAL_ADMIN_PASSWORD),
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
log.warning(
|
||||
"Initial admin account created. username=admin "
|
||||
"Please change the password after first login."
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_database()
|
||||
init_admin()
|
||||
async with scheduler_lifespan():
|
||||
log.info("Application ready")
|
||||
yield
|
||||
log.info("Application shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Backup System",
|
||||
description="数据备份管理系统 - MySQL / 目录 备份到本地 / S3",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
log.exception("Unhandled error on %s %s", request.method, request.url)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Internal server error: {type(exc).__name__}"},
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""ORM 模型集中导入,确保 Base.metadata 能识别所有表。"""
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.storage import Storage # noqa: F401
|
||||
from app.models.job import Job # noqa: F401
|
||||
from app.models.run import Run, RunLog # noqa: F401
|
||||
from app.models.setting import Setting # noqa: F401
|
||||
from app.models.restore import RestoreHistory # noqa: F401
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Job(Base):
|
||||
"""备份任务定义。
|
||||
|
||||
source_config_json 是 Fernet 加密的 JSON:
|
||||
- mysql: {"host", "port", "user", "password", "database"}
|
||||
- directory: {"path", "exclude_patterns": [...]}
|
||||
"""
|
||||
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False) # 'mysql' / 'directory'
|
||||
source_config_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
storage_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("storages.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
cron_expression: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
retention_count: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
|
||||
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
storage: Mapped["Storage"] = relationship(lazy="joined") # noqa: F821
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RestoreHistory(Base):
|
||||
"""恢复执行历史。"""
|
||||
|
||||
__tablename__ = "restore_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
target: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Run(Base):
|
||||
"""单次备份执行记录。"""
|
||||
|
||||
__tablename__ = "runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
trigger: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
artifact_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
artifact_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
artifact_checksum: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
log_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
job: Mapped["Job"] = relationship(lazy="joined") # noqa: F821
|
||||
logs: Mapped[list["RunLog"]] = relationship( # noqa: F821
|
||||
back_populates="run", cascade="all, delete-orphan", lazy="select"
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RunLog(Base):
|
||||
"""完整日志(可独立表以避免 runs 表臃肿)。"""
|
||||
|
||||
__tablename__ = "run_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
run: Mapped["Run"] = relationship(back_populates="logs")
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""系统级 KV 设置。"""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Storage(Base):
|
||||
"""备份目标存储(local 路径 或 S3 桶)。
|
||||
|
||||
config_json 是 Fernet 加密的 JSON 字符串,结构因 type 而异:
|
||||
- local: {"path": "/data/backups"}
|
||||
- s3: {"endpoint_url": "...", "region": "...", "bucket": "...",
|
||||
"access_key": "...", "secret_key": "...", "use_ssl": true,
|
||||
"path_prefix": "optional/prefix"}
|
||||
"""
|
||||
|
||||
__tablename__ = "storages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False) # 'local' / 's3'
|
||||
config_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} username={self.username}>"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""APScheduler 调度器:FastAPI lifespan 中启动/关闭。"""
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.job import Job
|
||||
from app.models.run import Run
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
_scheduler: Optional[AsyncIOScheduler] = None
|
||||
|
||||
|
||||
async def _execute_scheduled_job(job_id: int) -> None:
|
||||
"""调度器回调:创建 run 记录并启动执行。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.get(Job, job_id)
|
||||
if not job or not job.enabled:
|
||||
return
|
||||
run = Run(
|
||||
job_id=job.id,
|
||||
status="pending",
|
||||
trigger="scheduled",
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
run_id = run.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from app.core.executor import run_backup
|
||||
|
||||
asyncio.create_task(run_backup(run_id, trigger="scheduled"))
|
||||
|
||||
|
||||
def _register_job(job: Job) -> Optional[str]:
|
||||
"""注册一个 job 的 cron trigger,返回 APScheduler job id。"""
|
||||
assert _scheduler is not None
|
||||
if not job.enabled or not job.cron_expression:
|
||||
return None
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(job.cron_expression, timezone=settings.TZ)
|
||||
except Exception as e:
|
||||
log.error("Invalid cron for job %s: %s", job.name, e)
|
||||
return None
|
||||
sched_job = _scheduler.add_job(
|
||||
_execute_scheduled_job,
|
||||
trigger=trigger,
|
||||
args=[job.id],
|
||||
id=f"job-{job.id}",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=600,
|
||||
coalesce=True,
|
||||
)
|
||||
log.info("Registered scheduled job: id=%s name=%s cron=%s", job.id, job.name, job.cron_expression)
|
||||
return sched_job.id
|
||||
|
||||
|
||||
def _reload_all_jobs() -> None:
|
||||
"""从 DB 重新加载所有 enabled job。"""
|
||||
assert _scheduler is not None
|
||||
# 移除现有
|
||||
for sj in _scheduler.get_jobs():
|
||||
sj.remove()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
jobs = db.query(Job).filter(Job.enabled == True, Job.cron_expression.isnot(None)).all() # noqa: E712
|
||||
for j in jobs:
|
||||
_register_job(j)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def scheduler_lifespan():
|
||||
global _scheduler
|
||||
_scheduler = AsyncIOScheduler(timezone=settings.TZ)
|
||||
_scheduler.start()
|
||||
log.info("Scheduler started")
|
||||
try:
|
||||
_reload_all_jobs()
|
||||
yield
|
||||
finally:
|
||||
_scheduler.shutdown(wait=False)
|
||||
log.info("Scheduler stopped")
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def reschedule_job(job_id: int) -> None:
|
||||
"""job 更新后调用此函数重新注册。"""
|
||||
if _scheduler is None:
|
||||
return
|
||||
sched_id = f"job-{job_id}"
|
||||
# 移除旧的
|
||||
try:
|
||||
existing = _scheduler.get_job(sched_id)
|
||||
if existing:
|
||||
existing.remove()
|
||||
except Exception:
|
||||
pass
|
||||
# 重新加载
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.get(Job, job_id)
|
||||
if job:
|
||||
_register_job(job)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def remove_job(job_id: int) -> None:
|
||||
if _scheduler is None:
|
||||
return
|
||||
try:
|
||||
existing = _scheduler.get_job(f"job-{job_id}")
|
||||
if existing:
|
||||
existing.remove()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
detail: Optional[str] = None
|
||||
@@ -0,0 +1,96 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.schemas.storage import StorageOut
|
||||
|
||||
|
||||
class MySQLSourceConfig(BaseModel):
|
||||
host: str = Field(..., min_length=1)
|
||||
port: int = Field(3306, ge=1, le=65535)
|
||||
user: str = Field(..., min_length=1)
|
||||
password: str = Field("", description="可空(trust auth)")
|
||||
database: str = Field(..., min_length=1, description="要备份的库名")
|
||||
extra_args: str = Field("", description="额外 mysqldump 参数,如 '--skip-lock-tables'")
|
||||
|
||||
|
||||
class DirectorySourceConfig(BaseModel):
|
||||
path: str = Field(..., min_length=1, description="要备份的目录绝对路径")
|
||||
exclude_patterns: list[str] = Field(
|
||||
default_factory=lambda: [".DS_Store", "__pycache__", "node_modules", ".git"]
|
||||
)
|
||||
|
||||
|
||||
class JobBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
type: Literal["mysql", "directory"]
|
||||
source_config: dict
|
||||
storage_id: int
|
||||
cron_expression: Optional[str] = Field(None, description="标准 5 段 cron 表达式,空表示手动")
|
||||
enabled: bool = True
|
||||
retention_count: int = Field(7, ge=1, le=365)
|
||||
retention_days: Optional[int] = Field(None, ge=1, le=3650)
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
@field_validator("cron_expression")
|
||||
@classmethod
|
||||
def _check_cron(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v.strip() == "":
|
||||
return None
|
||||
v = v.strip()
|
||||
parts = v.split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("cron 必须是 5 段:分 时 日 月 周")
|
||||
return v
|
||||
|
||||
|
||||
class JobCreate(JobBase):
|
||||
pass
|
||||
|
||||
|
||||
class JobUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
source_config: Optional[dict] = None
|
||||
storage_id: Optional[int] = None
|
||||
cron_expression: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
retention_count: Optional[int] = Field(None, ge=1, le=365)
|
||||
retention_days: Optional[int] = Field(None, ge=1, le=3650)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class JobOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
cron_expression: Optional[str]
|
||||
enabled: bool
|
||||
retention_count: int
|
||||
retention_days: Optional[int]
|
||||
description: Optional[str]
|
||||
storage: StorageOut
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
status: str
|
||||
trigger: str
|
||||
started_at: Optional[datetime]
|
||||
finished_at: Optional[datetime]
|
||||
duration_seconds: Optional[int]
|
||||
artifact_size: Optional[int]
|
||||
error_message: Optional[str]
|
||||
|
||||
|
||||
class JobWithLastRun(JobOut):
|
||||
last_run: Optional[RunSummary] = None
|
||||
|
||||
|
||||
class JobRunResponse(BaseModel):
|
||||
run_id: int
|
||||
status: str
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RestoreTargetsResponse(BaseModel):
|
||||
"""列出某个 run 可恢复的目标元信息。"""
|
||||
|
||||
run_id: int
|
||||
job_type: str # 'mysql' / 'directory'
|
||||
artifact_size: Optional[int]
|
||||
artifact_checksum: Optional[str]
|
||||
suggested_targets: list[str]
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
run_id: int
|
||||
target_type: Literal["mysql_db", "directory_path"]
|
||||
target: str = Field(..., min_length=1, description="MySQL 库名 或 目录路径")
|
||||
options: dict = Field(default_factory=dict, description="额外选项,如 {'drop_existing': true}")
|
||||
|
||||
|
||||
class RestoreOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
run_id: int
|
||||
target: str
|
||||
status: str
|
||||
started_at: Optional[datetime]
|
||||
finished_at: Optional[datetime]
|
||||
error_message: Optional[str]
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.job import RunSummary
|
||||
|
||||
|
||||
class RunOut(RunSummary):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
job_id: int
|
||||
artifact_path: Optional[str]
|
||||
artifact_checksum: Optional[str]
|
||||
log_excerpt: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[RunOut]
|
||||
|
||||
|
||||
class RunTriggerResponse(BaseModel):
|
||||
run_id: int
|
||||
status: str
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
class SettingUpdate(BaseModel):
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class LocalStorageConfig(BaseModel):
|
||||
path: str = Field(..., min_length=1, description="本地存储根目录绝对路径")
|
||||
|
||||
|
||||
class S3StorageConfig(BaseModel):
|
||||
endpoint_url: Optional[str] = Field(None, description="MinIO/Ceph 自定义 endpoint,留空使用 AWS")
|
||||
region: str = Field("us-east-1")
|
||||
bucket: str = Field(..., min_length=1)
|
||||
access_key: str = Field(..., min_length=1)
|
||||
secret_key: str = Field(..., min_length=1)
|
||||
use_ssl: bool = True
|
||||
path_prefix: str = Field("", description="对象 key 前缀,可空")
|
||||
addressing_style: Literal["auto", "path", "virtual"] = "auto"
|
||||
|
||||
|
||||
class StorageBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
type: Literal["local", "s3"]
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class StorageCreate(StorageBase):
|
||||
config: dict
|
||||
|
||||
|
||||
class StorageUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
is_default: Optional[bool] = None
|
||||
config: Optional[dict] = None
|
||||
|
||||
|
||||
class StorageOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StorageTestResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
@@ -0,0 +1,35 @@
|
||||
"""JWT 与密码哈希工具。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(subject: str, extra: Optional[dict[str, Any]] = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
payload: dict[str, Any] = {"sub": subject, "exp": expire}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""解码 token,失败抛 JWTError。"""
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Fernet 对称加密工具:用于加密存储敏感字段(MySQL 密码、S3 SecretKey)。"""
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(get_settings().FERNET_KEY.encode())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_str(plain: str) -> str:
|
||||
"""加密字符串,返回 base64-urlsafe 字符串(可安全存数据库 Text 字段)。"""
|
||||
if plain is None:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plain.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_str(token: str) -> str:
|
||||
"""解密字符串。空字符串返回空字符串。"""
|
||||
if not token:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken as e:
|
||||
raise ValueError(f"Failed to decrypt token (FERNET_KEY 可能已被更改): {e}") from e
|
||||
|
||||
|
||||
def encrypt_dict(data: dict) -> str:
|
||||
"""加密字典:序列化为 JSON 再加密。"""
|
||||
import json
|
||||
|
||||
return encrypt_str(json.dumps(data, ensure_ascii=False))
|
||||
|
||||
|
||||
def decrypt_dict(token: str) -> dict:
|
||||
"""解密字典。"""
|
||||
import json
|
||||
|
||||
if not token:
|
||||
return {}
|
||||
return json.loads(decrypt_str(token))
|
||||
@@ -0,0 +1,50 @@
|
||||
"""日志配置:控制台 + 文件 + 单 run StringIO Tee。"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
_LOG_DIR = Path("logs")
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""应用启动时调用一次。"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(_settings.LOG_LEVEL.upper())
|
||||
|
||||
# 避免重复添加 handler(uvicorn 重载时)
|
||||
if root.handlers:
|
||||
return
|
||||
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# 控制台
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(fmt)
|
||||
root.addHandler(console)
|
||||
|
||||
# 文件
|
||||
log_file = _LOG_DIR / f"app-{datetime.now():%Y-%m-%d}.log"
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file, maxBytes=20 * 1024 * 1024, backupCount=10, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# 调整 uvicorn 噪音
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("apscheduler.scheduler").setLevel(logging.INFO)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
sqlalchemy==2.0.35
|
||||
alembic==1.13.3
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.12
|
||||
apscheduler==3.10.4
|
||||
aiofiles==24.1.0
|
||||
aioboto3==13.1.1
|
||||
cryptography==43.0.1
|
||||
httpx==0.27.2
|
||||
@@ -0,0 +1,47 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
image: backup-system/backend:latest
|
||||
container_name: backup-backend
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 容器内默认存储根目录
|
||||
STORAGE_LOCAL_ROOT: /app/data/backups
|
||||
DATABASE_URL: sqlite:////app/data/backup.db
|
||||
volumes:
|
||||
- backend-data:/app/data
|
||||
- backend-logs:/app/logs
|
||||
# 默认 local storage 根;若自定义 local storage 路径,可再挂载
|
||||
- local-backups:/app/data/backups
|
||||
ports:
|
||||
- "8765:8000"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
image: backup-system/frontend:latest
|
||||
container_name: backup-frontend
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
backend-logs:
|
||||
local-backups:
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据备份管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# 前端 SPA:所有非 /api 路径回退到 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 反代到后端
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 流式响应支持(大文件下载 / 日志)
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# 健康检查透传
|
||||
location /api/health {
|
||||
proxy_pass http://backend:8000/api/health;
|
||||
}
|
||||
|
||||
# 缓存静态资源
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "backup-system-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.21.0",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"axios": "^1.7.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AppRouter } from './router'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from './client'
|
||||
import type { MessageResponse, TokenResponse, User } from '../types'
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (payload: LoginPayload) =>
|
||||
api.post<TokenResponse>('/auth/login', payload).then((r) => r.data),
|
||||
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
||||
changePassword: (old_password: string, new_password: string) =>
|
||||
api
|
||||
.post<MessageResponse>('/auth/change-password', { old_password, new_password })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { message } from 'antd'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL,
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(error: AxiosError<{ detail?: string | { msg: string }[] }>) => {
|
||||
const status = error.response?.status
|
||||
const detail = error.response?.data?.detail
|
||||
|
||||
let msg: string
|
||||
if (Array.isArray(detail)) {
|
||||
msg = detail.map((d) => d.msg).join('; ')
|
||||
} else if (typeof detail === 'string') {
|
||||
msg = detail
|
||||
} else {
|
||||
msg = error.message
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
useAuthStore.getState().logout()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
} else if (status && status >= 400) {
|
||||
message.error(msg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
JobCreate,
|
||||
JobOut,
|
||||
JobRunResponse,
|
||||
JobUpdate,
|
||||
JobWithLastRun,
|
||||
} from '../types'
|
||||
|
||||
export const jobApi = {
|
||||
list: () => api.get<JobWithLastRun[]>('/jobs').then((r) => r.data),
|
||||
get: (id: number) => api.get<JobWithLastRun>(`/jobs/${id}`).then((r) => r.data),
|
||||
create: (payload: JobCreate) => api.post<JobOut>('/jobs', payload).then((r) => r.data),
|
||||
update: (id: number, payload: JobUpdate) =>
|
||||
api.put<JobOut>(`/jobs/${id}`, payload).then((r) => r.data),
|
||||
remove: (id: number) => api.delete(`/jobs/${id}`).then((r) => r.data),
|
||||
run: (id: number) =>
|
||||
api.post<JobRunResponse>(`/jobs/${id}/run`).then((r) => r.data),
|
||||
toggle: (id: number) =>
|
||||
api.post<JobOut>(`/jobs/${id}/toggle`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
RestoreOut,
|
||||
RestoreRequest,
|
||||
RestoreTargetsResponse,
|
||||
} from '../types'
|
||||
|
||||
export const restoreApi = {
|
||||
getTargets: (run_id: number) =>
|
||||
api
|
||||
.get<RestoreTargetsResponse>('/restore/targets', { params: { run_id } })
|
||||
.then((r) => r.data),
|
||||
create: (payload: RestoreRequest) =>
|
||||
api.post<RestoreOut>('/restore', payload).then((r) => r.data),
|
||||
history: (run_id?: number) =>
|
||||
api.get<RestoreOut[]>('/restore/history', { params: { run_id } }).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from './client'
|
||||
import type { RunListResponse, RunLogOut, RunOut } from '../types'
|
||||
|
||||
export const runApi = {
|
||||
list: (params?: { job_id?: number; status?: string; page?: number; page_size?: number }) =>
|
||||
api.get<RunListResponse>('/runs', { params }).then((r) => r.data),
|
||||
get: (id: number) => api.get<RunOut>(`/runs/${id}`).then((r) => r.data),
|
||||
getLog: (id: number) => api.get<RunLogOut>(`/runs/${id}/log`).then((r) => r.data),
|
||||
downloadUrl: (id: number) => `/api/runs/${id}/download`,
|
||||
remove: (id: number) => api.delete(`/runs/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
StorageCreate,
|
||||
StorageOut,
|
||||
StorageTestResponse,
|
||||
StorageUpdate,
|
||||
} from '../types'
|
||||
|
||||
export const storageApi = {
|
||||
list: () => api.get<StorageOut[]>('/storages').then((r) => r.data),
|
||||
get: (id: number) => api.get<StorageOut>(`/storages/${id}`).then((r) => r.data),
|
||||
create: (payload: StorageCreate) =>
|
||||
api.post<StorageOut>('/storages', payload).then((r) => r.data),
|
||||
update: (id: number, payload: StorageUpdate) =>
|
||||
api.put<StorageOut>(`/storages/${id}`, payload).then((r) => r.data),
|
||||
remove: (id: number) => api.delete(`/storages/${id}`).then((r) => r.data),
|
||||
test: (id: number) =>
|
||||
api.post<StorageTestResponse>(`/storages/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Layout as AntLayout, Menu, Button, Avatar, Dropdown, Space, theme } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ScheduleOutlined,
|
||||
HistoryOutlined,
|
||||
DatabaseOutlined,
|
||||
RollbackOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
import { useUiStore } from '../../stores/uiStore'
|
||||
|
||||
const { Sider, Header, Content } = AntLayout
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: <Link to="/dashboard">概览</Link> },
|
||||
{ key: '/jobs', icon: <ScheduleOutlined />, label: <Link to="/jobs">备份任务</Link> },
|
||||
{ key: '/history', icon: <HistoryOutlined />, label: <Link to="/history">运行历史</Link> },
|
||||
{ key: '/restore', icon: <RollbackOutlined />, label: <Link to="/restore">恢复</Link> },
|
||||
{ key: '/storages', icon: <DatabaseOutlined />, label: <Link to="/storages">存储目标</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: <Link to="/settings">设置</Link> },
|
||||
]
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuth()
|
||||
const { sidebarCollapsed, toggleSidebar } = useUiStore()
|
||||
const { token } = theme.useToken()
|
||||
|
||||
const selectedKey =
|
||||
menuItems.find((m) => location.pathname.startsWith(m.key))?.key || '/dashboard'
|
||||
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
theme="dark"
|
||||
collapsed={sidebarCollapsed}
|
||||
collapsible
|
||||
trigger={null}
|
||||
width={220}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 16,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{sidebarCollapsed ? 'BS' : '备份系统'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Header
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} size="small" />
|
||||
<span>{user?.username}</span>
|
||||
{user?.is_admin && <span style={{ color: token.colorTextSecondary }}>(admin)</span>}
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
padding: 24,
|
||||
background: token.colorBgLayout,
|
||||
minHeight: 'calc(100vh - 64px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { Result } from 'antd'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export function ProtectedRoute({ children, adminOnly = false }: { children: ReactNode; adminOnly?: boolean }) {
|
||||
const { token, user } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
if (adminOnly && !user.is_admin) {
|
||||
return <Result status="403" title="403" subTitle="需要管理员权限" />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export function useAuth() {
|
||||
const { user, token, loading, login, logout } = useAuthStore()
|
||||
return { user, token, loading, login, logout, isAdmin: user?.is_admin ?? false }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider, App as AntdApp } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
import App from './App'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin } from 'antd'
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
ScheduleOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { jobApi } from '../api/jobs'
|
||||
import { runApi } from '../api/runs'
|
||||
import type { JobWithLastRun, RunOut } from '../types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
function formatBytes(n: number | null | undefined): string {
|
||||
if (n == null) return '-'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [jobs, setJobs] = useState<JobWithLastRun[]>([])
|
||||
const [runs, setRuns] = useState<RunOut[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([jobApi.list(), runApi.list({ page: 1, page_size: 10 })])
|
||||
.then(([j, r]) => {
|
||||
setJobs(j)
|
||||
setRuns(r.items)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const last24h = runs.filter((r) =>
|
||||
r.started_at ? dayjs(r.started_at).isAfter(dayjs().subtract(24, 'hour')) : false,
|
||||
)
|
||||
const success24h = last24h.filter((r) => r.status === 'success').length
|
||||
const failed24h = last24h.filter((r) => r.status === 'failed').length
|
||||
const totalSize = runs.reduce((s, r) => s + (r.artifact_size || 0), 0)
|
||||
|
||||
if (loading) return <Spin />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3} style={{ marginTop: 0 }}>概览</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总任务数"
|
||||
value={jobs.length}
|
||||
prefix={<ScheduleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="存储目标"
|
||||
value={new Set(jobs.map((j) => j.storage.id)).size}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="24h 成功"
|
||||
value={success24h}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="24h 失败"
|
||||
value={failed24h}
|
||||
valueStyle={{ color: failed24h > 0 ? '#cf1322' : undefined }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="最近运行" extra={<Link to="/history">查看全部</Link>}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
dataSource={runs}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: ['job_id'],
|
||||
render: (id: number) => {
|
||||
const job = jobs.find((j) => j.id === id)
|
||||
return job?.name || `#${id}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => {
|
||||
const color =
|
||||
s === 'success' ? 'green' : s === 'failed' ? 'red' : s === 'running' ? 'blue' : 'default'
|
||||
const icon =
|
||||
s === 'success' ? <CheckCircleOutlined /> : s === 'failed' ? <CloseCircleOutlined /> : <ClockCircleOutlined />
|
||||
return <Tag color={color} icon={icon}>{s}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'artifact_size',
|
||||
width: 100,
|
||||
render: (n: number | null) => formatBytes(n),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 160,
|
||||
render: (t: string | null) => (t ? dayjs(t).format('MM-DD HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 80,
|
||||
render: (_: unknown, r: RunOut) => (
|
||||
<Link to={`/runs/${r.id}`}>详情</Link>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="任务列表">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
dataSource={jobs}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 80,
|
||||
render: (t: string) => <Tag>{t.toUpperCase()}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上次状态',
|
||||
width: 100,
|
||||
render: (_, j: JobWithLastRun) => {
|
||||
if (!j.last_run) return <Tag>未运行</Tag>
|
||||
const s = j.last_run.status
|
||||
const color =
|
||||
s === 'success' ? 'green' : s === 'failed' ? 'red' : 'default'
|
||||
return <Tag color={color}>{s}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '上次时间',
|
||||
width: 160,
|
||||
render: (_, j: JobWithLastRun) =>
|
||||
j.last_run?.started_at ? dayjs(j.last_run.started_at).format('MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Statistic
|
||||
title="最近 10 次备份总大小"
|
||||
value={formatBytes(totalSize)}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Select, Space, Table, Tag, Typography, Button, App } from 'antd'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { runApi } from '../api/runs'
|
||||
import { jobApi } from '../api/jobs'
|
||||
import type { JobWithLastRun, RunOut } from '../types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
function formatBytes(n: number | null | undefined): string {
|
||||
if (n == null) return '-'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export function History() {
|
||||
const { message } = App.useApp()
|
||||
const [runs, setRuns] = useState<RunOut[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [jobs, setJobs] = useState<JobWithLastRun[]>([])
|
||||
const [filterJob, setFilterJob] = useState<number | undefined>()
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true)
|
||||
runApi
|
||||
.list({ job_id: filterJob, status: filterStatus, page, page_size: pageSize })
|
||||
.then((r) => {
|
||||
setRuns(r.items)
|
||||
setTotal(r.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
jobApi.list().then(setJobs)
|
||||
}, [])
|
||||
|
||||
useEffect(fetch, [page, pageSize, filterJob, filterStatus])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3}>运行历史</Title>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<span>任务:</span>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
placeholder="全部"
|
||||
value={filterJob}
|
||||
onChange={setFilterJob}
|
||||
options={jobs.map((j) => ({ value: j.id, label: j.name }))}
|
||||
/>
|
||||
<span>状态:</span>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={[
|
||||
{ value: 'success', label: 'success' },
|
||||
{ value: 'failed', label: 'failed' },
|
||||
{ value: 'running', label: 'running' },
|
||||
{ value: 'pending', label: 'pending' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={fetch}>刷新</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={runs}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
showSizeChanger: true,
|
||||
}}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'job_id',
|
||||
render: (id: number) => jobs.find((j) => j.id === id)?.name || `#${id}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => {
|
||||
const color = s === 'success' ? 'green' : s === 'failed' ? 'red' : s === 'running' ? 'blue' : 'default'
|
||||
return <Tag color={color}>{s}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '触发',
|
||||
dataIndex: 'trigger',
|
||||
width: 90,
|
||||
render: (t: string) => <Tag>{t}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 170,
|
||||
render: (t: string | null) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration_seconds',
|
||||
width: 80,
|
||||
render: (s: number | null) => (s != null ? `${s}s` : '-'),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'artifact_size',
|
||||
width: 100,
|
||||
render: formatBytes,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, r: RunOut) => (
|
||||
<Space>
|
||||
<Link to={`/runs/${r.id}`}>详情</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JobForm } from './JobForm'
|
||||
|
||||
export function JobCreate() {
|
||||
return <JobForm mode="create" />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JobForm } from './JobForm'
|
||||
|
||||
export function JobEdit() {
|
||||
return <JobForm mode="edit" />
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Typography,
|
||||
App,
|
||||
Divider,
|
||||
Alert,
|
||||
} from 'antd'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { jobApi } from '../../api/jobs'
|
||||
import { storageApi } from '../../api/storages'
|
||||
import type { JobCreate, JobUpdate, JobWithLastRun, StorageOut } from '../../types'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
type: 'mysql' | 'directory'
|
||||
storage_id: number
|
||||
enabled: boolean
|
||||
cron_expression?: string
|
||||
retention_count: number
|
||||
retention_days?: number
|
||||
description?: string
|
||||
// MySQL
|
||||
mysql_host?: string
|
||||
mysql_port?: number
|
||||
mysql_user?: string
|
||||
mysql_password?: string
|
||||
mysql_database?: string
|
||||
mysql_extra_args?: string
|
||||
// Directory
|
||||
dir_path?: string
|
||||
dir_exclude_patterns?: string
|
||||
}
|
||||
|
||||
function toPayload(values: FormValues): JobCreate {
|
||||
const source_config =
|
||||
values.type === 'mysql'
|
||||
? {
|
||||
host: values.mysql_host!,
|
||||
port: values.mysql_port || 3306,
|
||||
user: values.mysql_user!,
|
||||
password: values.mysql_password || '',
|
||||
database: values.mysql_database!,
|
||||
extra_args: values.mysql_extra_args || '',
|
||||
}
|
||||
: {
|
||||
path: values.dir_path!,
|
||||
exclude_patterns: values.dir_exclude_patterns
|
||||
? values.dir_exclude_patterns.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
}
|
||||
|
||||
return {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
storage_id: values.storage_id,
|
||||
source_config,
|
||||
enabled: values.enabled,
|
||||
cron_expression: values.cron_expression?.trim() || undefined,
|
||||
retention_count: values.retention_count,
|
||||
retention_days: values.retention_days,
|
||||
description: values.description,
|
||||
}
|
||||
}
|
||||
|
||||
export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const [storages, setStorages] = useState<StorageOut[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [existing, setExisting] = useState<JobWithLastRun | null>(null)
|
||||
const jobType = Form.useWatch('type', form)
|
||||
|
||||
useEffect(() => {
|
||||
storageApi.list().then(setStorages)
|
||||
if (mode === 'edit' && id) {
|
||||
jobApi
|
||||
.get(Number(id))
|
||||
.then((j) => {
|
||||
setExisting(j)
|
||||
const flat: Partial<FormValues> = {
|
||||
name: j.name,
|
||||
type: j.type,
|
||||
storage_id: j.storage.id,
|
||||
enabled: j.enabled,
|
||||
cron_expression: j.cron_expression || '',
|
||||
retention_count: j.retention_count,
|
||||
retention_days: j.retention_days ?? undefined,
|
||||
description: j.description || '',
|
||||
}
|
||||
// 反解 source_config(需要解密后的,前端拿不到 - 用空 source_config 占位让用户重填)
|
||||
form.setFieldsValue(flat as FormValues)
|
||||
})
|
||||
}
|
||||
}, [mode, id])
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await jobApi.create(toPayload(values))
|
||||
message.success('创建成功')
|
||||
} else {
|
||||
const payload: JobUpdate = {
|
||||
name: values.name,
|
||||
storage_id: values.storage_id,
|
||||
enabled: values.enabled,
|
||||
cron_expression: values.cron_expression?.trim() || null,
|
||||
retention_count: values.retention_count,
|
||||
retention_days: values.retention_days ?? null,
|
||||
description: values.description ?? null,
|
||||
}
|
||||
await jobApi.update(Number(id), payload)
|
||||
message.success('已更新')
|
||||
}
|
||||
navigate('/jobs')
|
||||
} catch {
|
||||
/* */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3}>{mode === 'create' ? '新建备份任务' : `编辑任务 #${id}`}</Title>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
initialValues={{
|
||||
type: 'mysql',
|
||||
enabled: true,
|
||||
retention_count: 7,
|
||||
mysql_port: 3306,
|
||||
}}
|
||||
style={{ maxWidth: 720 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="任务名称"
|
||||
rules={[{ required: true, message: '请输入任务名称' }]}
|
||||
>
|
||||
<Input placeholder="如:daily-mysql-backup" disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="type" label="数据源类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
options={[
|
||||
{ value: 'mysql', label: 'MySQL 数据库' },
|
||||
{ value: 'directory', label: '服务器目录' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{mode === 'edit' && existing && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="编辑模式下数据源配置(密码/目录)不可见,请通过重建任务来修改。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{jobType === 'mysql' && (
|
||||
<>
|
||||
<Divider orientation="left">MySQL 数据源</Divider>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="mysql_host" label="主机" rules={[{ required: jobType === 'mysql' }]} style={{ flex: 3, marginRight: 8 }}>
|
||||
<Input placeholder="127.0.0.1" disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysql_port" label="端口" style={{ flex: 1 }}>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item name="mysql_user" label="用户名" rules={[{ required: jobType === 'mysql' }]}>
|
||||
<Input disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysql_password" label="密码" extra="留空表示无密码">
|
||||
<Input.Password placeholder="数据库密码" disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysql_database" label="数据库名" rules={[{ required: jobType === 'mysql' }]}>
|
||||
<Input disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mysql_extra_args" label="额外 mysqldump 参数" extra="如:--skip-lock-tables">
|
||||
<Input disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{jobType === 'directory' && (
|
||||
<>
|
||||
<Divider orientation="left">目录数据源</Divider>
|
||||
<Form.Item name="dir_path" label="要备份的目录" rules={[{ required: jobType === 'directory' }]}>
|
||||
<Input placeholder="/var/log" disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="dir_exclude_patterns"
|
||||
label="排除规则(逗号分隔)"
|
||||
extra="glob 模式,按文件名匹配,如:.DS_Store,__pycache__,node_modules"
|
||||
>
|
||||
<Input disabled={mode === 'edit'} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">存储与调度</Divider>
|
||||
|
||||
<Form.Item name="storage_id" label="存储目标" rules={[{ required: true, message: '请选择存储目标' }]}>
|
||||
<Select
|
||||
placeholder="选择存储目标"
|
||||
options={storages.map((s) => ({ value: s.id, label: `${s.name} (${s.type})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="cron_expression"
|
||||
label="Cron 表达式"
|
||||
extra="5 段:分 时 日 月 周。留空表示仅手动触发。例:0 3 * * * = 每天凌晨3点"
|
||||
>
|
||||
<Input placeholder="0 3 * * *" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">保留策略</Divider>
|
||||
|
||||
<Space size="large">
|
||||
<Form.Item name="retention_count" label="保留最近 N 个备份">
|
||||
<InputNumber min={1} max={365} />
|
||||
</Form.Item>
|
||||
<Form.Item name="retention_days" label="保留 N 天内(留空不限)">
|
||||
<InputNumber min={1} max={3650} placeholder="天" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{mode === 'create' ? '创建' : '保存'}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/jobs')}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, PlayCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { jobApi } from '../../api/jobs'
|
||||
import type { JobWithLastRun } from '../../types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export function JobList() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const [jobs, setJobs] = useState<JobWithLastRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true)
|
||||
jobApi
|
||||
.list()
|
||||
.then(setJobs)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetch, [])
|
||||
|
||||
const onRun = async (id: number) => {
|
||||
try {
|
||||
const resp = await jobApi.run(id)
|
||||
message.success(`已触发备份,run_id=${resp.run_id}`)
|
||||
setTimeout(fetch, 500)
|
||||
} catch {
|
||||
/* handled by interceptor */
|
||||
}
|
||||
}
|
||||
|
||||
const onToggle = async (id: number) => {
|
||||
try {
|
||||
await jobApi.toggle(id)
|
||||
fetch()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
try {
|
||||
await jobApi.remove(id)
|
||||
message.success('已删除')
|
||||
fetch()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>备份任务</Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/jobs/new')}>
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={jobs}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 90,
|
||||
render: (t: string) => <Tag color={t === 'mysql' ? 'blue' : 'geekblue'}>{t.toUpperCase()}</Tag>,
|
||||
},
|
||||
{ title: '存储', dataIndex: ['storage', 'name'] },
|
||||
{
|
||||
title: 'Cron',
|
||||
dataIndex: 'cron_expression',
|
||||
width: 140,
|
||||
render: (c: string | null) => (c ? <Tag>{c}</Tag> : <Tag>手动</Tag>),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 70,
|
||||
render: (e: boolean, r) => (
|
||||
<Switch checked={e} size="small" onChange={() => onToggle(r.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上次状态',
|
||||
width: 100,
|
||||
render: (_, r: JobWithLastRun) => {
|
||||
if (!r.last_run) return <Tag>未运行</Tag>
|
||||
const s = r.last_run.status
|
||||
const color = s === 'success' ? 'green' : s === 'failed' ? 'red' : 'blue'
|
||||
return <Tag color={color}>{s}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '上次时间',
|
||||
width: 170,
|
||||
render: (_, r: JobWithLastRun) =>
|
||||
r.last_run?.started_at ? dayjs(r.last_run.started_at).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Tooltip title="立即执行">
|
||||
<Button size="small" type="primary" icon={<PlayCircleOutlined />} onClick={() => onRun(r.id)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/jobs/${r.id}/edit`)} />
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
description="将同时删除所有运行历史"
|
||||
onConfirm={() => onDelete(r.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Card, Typography, App } from 'antd'
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export function Login() {
|
||||
const { login, token, loading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
if (token) {
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname || '/dashboard'
|
||||
return <Navigate to={from} replace />
|
||||
}
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
message.success('登录成功')
|
||||
navigate('/dashboard')
|
||||
} catch (e) {
|
||||
// 已由 axios 拦截器提示
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 380, boxShadow: '0 8px 32px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
数据备份管理系统
|
||||
</Title>
|
||||
<Typography.Text type="secondary">登录以继续</Typography.Text>
|
||||
</div>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block size="large">
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
} from 'antd'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { restoreApi } from '../api/restore'
|
||||
import { runApi } from '../api/runs'
|
||||
import { jobApi } from '../api/jobs'
|
||||
import type { JobWithLastRun, RestoreTargetsResponse, RunOut } from '../types'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export function Restore() {
|
||||
const { message, modal } = App.useApp()
|
||||
const [params] = useSearchParams()
|
||||
const [step, setStep] = useState(0)
|
||||
const [runs, setRuns] = useState<RunOut[]>([])
|
||||
const [jobs, setJobs] = useState<JobWithLastRun[]>([])
|
||||
const [selectedRun, setSelectedRun] = useState<RunOut | null>(null)
|
||||
const [targets, setTargets] = useState<RestoreTargetsResponse | null>(null)
|
||||
const [targetType, setTargetType] = useState<'mysql_db' | 'directory_path'>('mysql_db')
|
||||
const [target, setTarget] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
runApi.list({ status: 'success', page: 1, page_size: 50 }),
|
||||
jobApi.list(),
|
||||
]).then(([r, j]) => {
|
||||
setRuns(r.items)
|
||||
setJobs(j)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const rid = params.get('run_id')
|
||||
if (rid) {
|
||||
const r = runs.find((x) => String(x.id) === rid)
|
||||
if (r) {
|
||||
setSelectedRun(r)
|
||||
setStep(1)
|
||||
loadTargets(r.id)
|
||||
}
|
||||
}
|
||||
}, [params, runs])
|
||||
|
||||
const loadTargets = async (runId: number) => {
|
||||
const t = await restoreApi.getTargets(runId)
|
||||
setTargets(t)
|
||||
setTargetType(t.job_type === 'mysql' ? 'mysql_db' : 'directory_path')
|
||||
setTarget(t.suggested_targets[0] || '')
|
||||
}
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (!selectedRun || !target.trim()) {
|
||||
message.warning('请选择目标')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '确认恢复?',
|
||||
content: `将覆盖目标 ${target}。建议先备份当前状态再继续。`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await restoreApi.create({
|
||||
run_id: selectedRun.id,
|
||||
target_type: targetType,
|
||||
target: target.trim(),
|
||||
})
|
||||
message.success('恢复任务已提交,请到运行历史查看')
|
||||
setStep(2)
|
||||
} catch {
|
||||
/* */
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3}>从备份恢复</Title>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Steps
|
||||
current={step}
|
||||
items={[
|
||||
{ title: '选择备份' },
|
||||
{ title: '选择目标' },
|
||||
{ title: '完成' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{step === 0 && (
|
||||
<Card title="选择一个成功的备份">
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={runs}
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'job_id',
|
||||
render: (id: number) => jobs.find((j) => j.id === id)?.name || `#${id}`,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
render: (_, r: RunOut) => jobs.find((j) => j.id === r.job_id)?.type?.toUpperCase(),
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
render: (t: string | null) => (t ? new Date(t).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, r: RunOut) => (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setSelectedRun(r)
|
||||
setStep(1)
|
||||
loadTargets(r.id)
|
||||
}}
|
||||
>
|
||||
选择
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 1 && selectedRun && targets && (
|
||||
<Card title="选择恢复目标">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`警告:将覆盖目标 ${target || '(待填)'} 的现有数据,请提前备份。`}
|
||||
/>
|
||||
<Form layout="vertical" style={{ maxWidth: 600 }}>
|
||||
<Form.Item label="备份">
|
||||
<Tag color="blue">{jobs.find((j) => j.id === selectedRun.job_id)?.name}</Tag>
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
{selectedRun.started_at && new Date(selectedRun.started_at).toLocaleString()}
|
||||
</span>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标类型">
|
||||
<Radio.Group
|
||||
value={targetType}
|
||||
onChange={(e) => setTargetType(e.target.value)}
|
||||
disabled
|
||||
>
|
||||
<Radio value="mysql_db">MySQL 数据库</Radio>
|
||||
<Radio value="directory_path">目录路径</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={targetType === 'mysql_db' ? '目标数据库名' : '目标目录绝对路径'}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder={targets.suggested_targets[0] || ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" danger loading={submitting} onClick={onConfirm}>
|
||||
执行恢复
|
||||
</Button>
|
||||
<Button onClick={() => setStep(0)}>返回</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Card>
|
||||
<Alert
|
||||
type="success"
|
||||
message="恢复任务已提交"
|
||||
description={
|
||||
<span>
|
||||
请到「运行历史」查看对应 run 的日志输出,或回到{' '}
|
||||
<a href="/history">运行历史</a>。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Button style={{ marginTop: 16 }} onClick={() => setStep(0)}>
|
||||
再恢复一个
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Card, Descriptions, Tag, Typography, Button, Space, Spin, Alert } from 'antd'
|
||||
import { DownloadOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||
import { runApi } from '../api/runs'
|
||||
import { jobApi } from '../api/jobs'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import type { JobWithLastRun, RunLogOut, RunOut } from '../types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
function formatBytes(n: number | null | undefined): string {
|
||||
if (n == null) return '-'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export function RunDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { token } = useAuth()
|
||||
const [run, setRun] = useState<RunOut | null>(null)
|
||||
const [log, setLog] = useState<RunLogOut | null>(null)
|
||||
const [job, setJob] = useState<JobWithLastRun | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
Promise.all([runApi.get(Number(id)), runApi.getLog(Number(id))])
|
||||
.then(async ([r, l]) => {
|
||||
setRun(r)
|
||||
setLog(l)
|
||||
const j = await jobApi.get(r.job_id)
|
||||
setJob(j)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return <Spin />
|
||||
if (!run) return <Alert type="error" message="Run not found" />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>运行详情 #{run.id}</Title>
|
||||
{run.status === 'success' && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={runApi.downloadUrl(run.id)}
|
||||
// 用 fetch+blob 下载避免浏览器拦截
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
const resp = await fetch(runApi.downloadUrl(run.id), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
})
|
||||
const blob = await resp.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = run.artifact_path?.split('/').pop() || `run-${run.id}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
>
|
||||
下载备份
|
||||
</Button>
|
||||
)}
|
||||
{run.status === 'success' && job && (
|
||||
<Link to={`/restore?run_id=${run.id}`}>
|
||||
<Button icon={<RollbackOutlined />}>从此备份恢复</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Card title="基本信息">
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={run.status === 'success' ? 'green' : run.status === 'failed' ? 'red' : 'blue'}>
|
||||
{run.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="触发方式">
|
||||
<Tag>{run.trigger}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务">
|
||||
{job?.name || `#${run.job_id}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{job?.type?.toUpperCase()}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{run.started_at ? dayjs(run.started_at).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">
|
||||
{run.finished_at ? dayjs(run.finished_at).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">
|
||||
{run.duration_seconds != null ? `${run.duration_seconds}s` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="大小">{formatBytes(run.artifact_size)}</Descriptions.Item>
|
||||
<Descriptions.Item label="SHA256" span={2}>
|
||||
{run.artifact_checksum || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对象路径" span={2}>
|
||||
<code>{run.artifact_path || '-'}</code>
|
||||
</Descriptions.Item>
|
||||
{run.error_message && (
|
||||
<Descriptions.Item label="错误" span={2}>
|
||||
<Alert type="error" message={run.error_message} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="执行日志" style={{ marginTop: 16 }}>
|
||||
<pre
|
||||
style={{
|
||||
background: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: 16,
|
||||
borderRadius: 6,
|
||||
maxHeight: 480,
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{log?.content || run.log_excerpt || '(无日志)'}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, Typography, App, Space } from 'antd'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { authApi } from '../api/auth'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
export function Settings() {
|
||||
const { user, logout } = useAuth()
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<{ old_password: string; new_password: string; confirm: string }>()
|
||||
|
||||
const onChangePassword = async (v: { old_password: string; new_password: string; confirm: string }) => {
|
||||
if (v.new_password !== v.confirm) {
|
||||
message.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await authApi.changePassword(v.old_password, v.new_password)
|
||||
message.success('密码已修改,请重新登录')
|
||||
setTimeout(() => {
|
||||
logout()
|
||||
window.location.href = '/login'
|
||||
}, 800)
|
||||
} catch {
|
||||
/* */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3}>设置</Title>
|
||||
|
||||
<Card title="账号信息" style={{ marginBottom: 16 }}>
|
||||
<p>用户名:{user?.username}</p>
|
||||
<p>角色:{user?.is_admin ? '管理员' : '普通用户'}</p>
|
||||
<p>创建时间:{user?.created_at && new Date(user.created_at).toLocaleString()}</p>
|
||||
</Card>
|
||||
|
||||
<Card title="修改密码">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onChangePassword}
|
||||
style={{ maxWidth: 480 }}
|
||||
>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label="新密码"
|
||||
rules={[{ required: true, min: 8, message: '至少 8 个字符' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
rules={[{ required: true, min: 8 }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
修改密码
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Divider,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined } from '@ant-design/icons'
|
||||
import { storageApi } from '../api/storages'
|
||||
import type { LocalStorageConfig, S3StorageConfig, StorageCreate, StorageOut, StorageUpdate } from '../types'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
is_default: boolean
|
||||
// local
|
||||
local_path?: string
|
||||
// s3
|
||||
s3_endpoint_url?: string
|
||||
s3_region?: string
|
||||
s3_bucket?: string
|
||||
s3_access_key?: string
|
||||
s3_secret_key?: string
|
||||
s3_use_ssl?: boolean
|
||||
s3_path_prefix?: string
|
||||
}
|
||||
|
||||
export function Storages() {
|
||||
const { message } = App.useApp()
|
||||
const [items, setItems] = useState<StorageOut[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<StorageOut | null>(null)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true)
|
||||
storageApi.list().then(setItems).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetch, [])
|
||||
|
||||
const onCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ type: 'local', is_default: false, s3_use_ssl: true, s3_region: 'us-east-1' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const onEdit = (row: StorageOut) => {
|
||||
setEditing(row)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
is_default: row.is_default,
|
||||
// config 明文不返回(后端不暴露),让用户重填
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const v = await form.validateFields()
|
||||
const config: LocalStorageConfig | S3StorageConfig =
|
||||
v.type === 'local'
|
||||
? { path: v.local_path! }
|
||||
: {
|
||||
endpoint_url: v.s3_endpoint_url || undefined,
|
||||
region: v.s3_region || 'us-east-1',
|
||||
bucket: v.s3_bucket!,
|
||||
access_key: v.s3_access_key!,
|
||||
secret_key: v.s3_secret_key!,
|
||||
use_ssl: v.s3_use_ssl ?? true,
|
||||
path_prefix: v.s3_path_prefix || '',
|
||||
}
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
const payload: StorageUpdate = {
|
||||
name: v.name,
|
||||
is_default: v.is_default,
|
||||
config,
|
||||
}
|
||||
await storageApi.update(editing.id, payload)
|
||||
message.success('已更新')
|
||||
} else {
|
||||
const payload: StorageCreate = {
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
is_default: v.is_default,
|
||||
config,
|
||||
}
|
||||
await storageApi.create(payload)
|
||||
message.success('已创建')
|
||||
}
|
||||
setOpen(false)
|
||||
fetch()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: number) => {
|
||||
try {
|
||||
await storageApi.remove(id)
|
||||
message.success('已删除')
|
||||
fetch()
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async (id: number) => {
|
||||
try {
|
||||
const r = await storageApi.test(id)
|
||||
if (r.ok) message.success(r.message)
|
||||
else message.error(r.message)
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
const typeWatch = Form.useWatch('type', form)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>存储目标</Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
|
||||
新建存储
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name', width: 200 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 100,
|
||||
render: (t: string) => <Tag color={t === 's3' ? 'geekblue' : 'blue'}>{t.toUpperCase()}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'is_default',
|
||||
width: 80,
|
||||
render: (d: boolean) => (d ? <Tag color="green">是</Tag> : '-'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
render: (t: string) => new Date(t).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 280,
|
||||
render: (_, r: StorageOut) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<ApiOutlined />} onClick={() => onTest(r.id)}>
|
||||
测试
|
||||
</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => onDelete(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
title={editing ? `编辑存储 #${editing.id}` : '新建存储'}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={onSubmit}
|
||||
width={620}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!!editing}
|
||||
options={[
|
||||
{ value: 'local', label: '本地目录' },
|
||||
{ value: 's3', label: 'S3 兼容对象存储' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{typeWatch === 'local' && (
|
||||
<Form.Item
|
||||
name="local_path"
|
||||
label="本地路径"
|
||||
extra="容器内绝对路径。容器启动时已挂载 ./data/backups 到 /app/data/backups"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="/app/data/backups" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{typeWatch === 's3' && (
|
||||
<>
|
||||
<Form.Item name="s3_endpoint_url" label="Endpoint URL" extra="AWS S3 留空;MinIO/Ceph 填 http://host:port">
|
||||
<Input placeholder="http://minio:9000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_region" label="Region">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_bucket" label="Bucket" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_access_key" label="Access Key" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_secret_key" label="Secret Key" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_use_ssl" label="Use SSL" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="s3_path_prefix" label="路径前缀" extra="所有对象 key 前缀,可用于按环境/客户分目录">
|
||||
<Input placeholder="prod/backups" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Form.Item name="is_default" label="设为默认存储" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { JobList } from './pages/Jobs/JobList'
|
||||
import { JobCreate } from './pages/Jobs/JobCreate'
|
||||
import { JobEdit } from './pages/Jobs/JobEdit'
|
||||
import { History } from './pages/History'
|
||||
import { RunDetail } from './pages/RunDetail'
|
||||
import { Storages } from './pages/Storages'
|
||||
import { Restore } from './pages/Restore'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="jobs" element={<JobList />} />
|
||||
<Route path="jobs/new" element={<JobCreate />} />
|
||||
<Route path="jobs/:id/edit" element={<JobEdit />} />
|
||||
<Route path="history" element={<History />} />
|
||||
<Route path="runs/:id" element={<RunDetail />} />
|
||||
<Route path="storages" element={<Storages />} />
|
||||
<Route path="restore" element={<Restore />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '../types'
|
||||
import { authApi } from '../api/auth'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
fetchMe: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
loading: false,
|
||||
login: async (username, password) => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
const tokenResp = await authApi.login({ username, password })
|
||||
set({ token: tokenResp.access_token })
|
||||
const user = await authApi.me()
|
||||
set({ user, loading: false })
|
||||
} catch (e) {
|
||||
set({ loading: false })
|
||||
throw e
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
set({ token: null, user: null })
|
||||
},
|
||||
fetchMe: async () => {
|
||||
if (!get().token) return
|
||||
try {
|
||||
const user = await authApi.me()
|
||||
set({ user })
|
||||
} catch {
|
||||
set({ token: null, user: null })
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'backup-auth',
|
||||
partialize: (s) => ({ token: s.token, user: s.user }),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UiState {
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
}))
|
||||
@@ -0,0 +1,181 @@
|
||||
// ===== 通用 =====
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
// ===== 存储 =====
|
||||
export interface StorageOut {
|
||||
id: number
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LocalStorageConfig {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface S3StorageConfig {
|
||||
endpoint_url?: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key: string
|
||||
secret_key: string
|
||||
use_ssl: boolean
|
||||
path_prefix?: string
|
||||
addressing_style?: 'auto' | 'path' | 'virtual'
|
||||
}
|
||||
|
||||
export interface StorageCreate {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
is_default?: boolean
|
||||
config: LocalStorageConfig | S3StorageConfig
|
||||
}
|
||||
|
||||
export interface StorageUpdate {
|
||||
name?: string
|
||||
is_default?: boolean
|
||||
config?: LocalStorageConfig | S3StorageConfig
|
||||
}
|
||||
|
||||
export interface StorageTestResponse {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// ===== Job =====
|
||||
export interface MySQLSourceConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password?: string
|
||||
database: string
|
||||
extra_args?: string
|
||||
}
|
||||
|
||||
export interface DirectorySourceConfig {
|
||||
path: string
|
||||
exclude_patterns?: string[]
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
id: number
|
||||
status: string
|
||||
trigger: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
duration_seconds: number | null
|
||||
artifact_size: number | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
export interface JobOut {
|
||||
id: number
|
||||
name: string
|
||||
type: 'mysql' | 'directory'
|
||||
cron_expression: string | null
|
||||
enabled: boolean
|
||||
retention_count: number
|
||||
retention_days: number | null
|
||||
description: string | null
|
||||
storage: StorageOut
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface JobWithLastRun extends JobOut {
|
||||
last_run: RunSummary | null
|
||||
}
|
||||
|
||||
export interface JobCreate {
|
||||
name: string
|
||||
type: 'mysql' | 'directory'
|
||||
source_config: MySQLSourceConfig | DirectorySourceConfig
|
||||
storage_id: number
|
||||
cron_expression?: string
|
||||
enabled?: boolean
|
||||
retention_count?: number
|
||||
retention_days?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface JobUpdate {
|
||||
name?: string
|
||||
source_config?: MySQLSourceConfig | DirectorySourceConfig
|
||||
storage_id?: number
|
||||
cron_expression?: string | null
|
||||
enabled?: boolean
|
||||
retention_count?: number
|
||||
retention_days?: number | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// ===== Run =====
|
||||
export interface RunOut extends RunSummary {
|
||||
job_id: number
|
||||
artifact_path: string | null
|
||||
artifact_checksum: string | null
|
||||
log_excerpt: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RunLogOut {
|
||||
id: number
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RunListResponse {
|
||||
total: number
|
||||
items: RunOut[]
|
||||
}
|
||||
|
||||
export interface JobRunResponse {
|
||||
run_id: number
|
||||
status: string
|
||||
}
|
||||
|
||||
// ===== Restore =====
|
||||
export interface RestoreTargetsResponse {
|
||||
run_id: number
|
||||
job_type: string
|
||||
artifact_size: number | null
|
||||
artifact_checksum: string | null
|
||||
suggested_targets: string[]
|
||||
}
|
||||
|
||||
export interface RestoreRequest {
|
||||
run_id: number
|
||||
target_type: 'mysql_db' | 'directory_path'
|
||||
target: string
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RestoreOut {
|
||||
id: number
|
||||
run_id: number
|
||||
target: string
|
||||
status: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1500,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user