feat: ETCD 备份 + 邮件/飞书通知

ETCD 备份(新增类型):
- 后端 core/backup/etcd.py:调用 etcdctl snapshot save(v3.5.17 二进制)
  生成快照文件,与 META.txt 一起 tar.gz 流式打包上传。
- 配置支持 endpoints(逗号分隔多节点)、basic auth、TLS(cacert/cert/key)。
- Dockerfile 下载 etcdctl 二进制(绕过 apt 签名问题)。

通知模块:
- 核心 app/core/notify.py:
  - 邮件:smtplib + MIMEMultipart(线程池里跑避免阻塞)
  - 飞书:httpx 调自定义机器人 webhook,发 interactive card
- settings API:
  - GET/PUT /api/settings/notifications(密码脱敏 *** 编辑占位)
  - POST /api/settings/notifications/test 测试通道
- Job 增加 notify_on 字段(none/failure/all),Alembic 0002 迁移。
- executor 在 run 完成后根据 job.notify_on 与 run.status 异步触发通知,
  重新加载最新 run 信息生成上下文。

前端:
- JobForm 加 ETCD 表单字段与 notify_on 选择器;type 选项加 etcd。
- JobList 显示通知策略徽标。
- Settings 页加通知配置卡片:邮件(折叠面板 + 测试按钮)+ 飞书 + 保存。
- 新增 api/settings.ts。
- 类型扩展:EtcdSourceConfig、NotificationsConfig、SmtpConfig。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
publ
2026-06-23 11:24:53 +08:00
parent 5313bc9a3a
commit 07b2016f33
15 changed files with 996 additions and 14 deletions
+11
View File
@@ -27,6 +27,17 @@ RUN set -eux; \
rm -rf /tmp/influx.tar.gz /tmp/influx; \
influx version
# 安装 etcdctl(直接下载二进制)
RUN set -eux; \
ETCD_VERSION=v3.5.17; \
curl -fsSL "https://github.com/etcd-io/etcd/releases/download/${ETCD_VERSION}/etcd-${ETCD_VERSION}-linux-amd64.tar.gz" \
-o /tmp/etcd.tar.gz; \
tar -xzf /tmp/etcd.tar.gz -C /tmp; \
mv /tmp/etcd-${ETCD_VERSION}-linux-amd64/etcdctl /usr/local/bin/etcdctl; \
chmod +x /usr/local/bin/etcdctl; \
rm -rf /tmp/etcd.tar.gz /tmp/etcd-${ETCD_VERSION}-linux-amd64; \
etcdctl version
WORKDIR /app
# 先安装依赖以利用 Docker 缓存
@@ -0,0 +1,30 @@
"""add notify_on to jobs
Revision ID: 0002_add_notify_on
Revises: 0001_initial
Create Date: 2026-06-23 11:30:00
"""
from alembic import op
import sqlalchemy as sa
revision = "0002_add_notify_on"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"jobs",
sa.Column(
"notify_on",
sa.String(16),
nullable=False,
server_default="none",
),
)
def downgrade() -> None:
op.drop_column("jobs", "notify_on")
+6
View File
@@ -12,6 +12,7 @@ from app.models.run import Run
from app.models.storage import Storage
from app.schemas.job import (
DirectorySourceConfig,
EtcdSourceConfig,
InfluxDBSourceConfigV1,
InfluxDBSourceConfigV2,
JobCreate,
@@ -55,6 +56,8 @@ def _validate_source(type_: str, cfg: dict) -> dict:
if ver == "2":
return InfluxDBSourceConfigV2(**cfg).model_dump()
raise HTTPException(status_code=400, detail="InfluxDB config 缺少 version (1 或 2)")
if type_ == "etcd":
return EtcdSourceConfig(**cfg).model_dump()
raise HTTPException(status_code=400, detail=f"Unsupported job type: {type_}")
@@ -96,6 +99,7 @@ def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> JobOut:
storage_id=payload.storage_id,
cron_expression=payload.cron_expression,
enabled=payload.enabled,
notify_on=payload.notify_on,
retention_count=payload.retention_count,
retention_days=payload.retention_days,
description=payload.description,
@@ -147,6 +151,8 @@ def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) ->
job.cron_expression = payload.cron_expression or None
if payload.enabled is not None:
job.enabled = payload.enabled
if payload.notify_on is not None:
job.notify_on = payload.notify_on
if payload.retention_count is not None:
job.retention_count = payload.retention_count
if payload.retention_days is not None:
+2 -1
View File
@@ -1,7 +1,7 @@
"""API 总路由。"""
from fastapi import APIRouter
from app.api import auth, jobs, restore, runs, storages
from app.api import auth, jobs, restore, runs, settings, storages
api_router = APIRouter()
api_router.include_router(auth.router)
@@ -9,3 +9,4 @@ api_router.include_router(storages.router)
api_router.include_router(jobs.router)
api_router.include_router(runs.router)
api_router.include_router(restore.router)
api_router.include_router(settings.router)
+154
View File
@@ -0,0 +1,154 @@
"""系统设置 API:通知配置(SMTP + Feishu webhook)。"""
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr, Field
from app.deps import AdminUser, DBSession
from app.models.setting import Setting
from app.utils.crypto import decrypt_dict, encrypt_dict
router = APIRouter(prefix="/settings", tags=["settings"])
# Settings key
_NOTIFY_KEY = "notifications"
# 脱敏字段
_SENSITIVE = {"password", "feishu_webhook_url"}
def _redact(d: dict) -> dict:
return {k: ("***" if k in _SENSITIVE and v else v) for k, v in d.items()}
def _unredact(stored: dict, incoming: dict) -> dict:
"""编辑时:若 incoming 中脱敏字段为 '***',保留 stored 原值。"""
for k in _SENSITIVE:
if incoming.get(k) == "***" and k in stored:
incoming[k] = stored[k]
return incoming
def _load(db, key: str) -> dict:
row = db.get(Setting, key)
if not row:
return {}
try:
return decrypt_dict(row.value)
except Exception:
return {}
def _save(db, key: str, value: str) -> None:
row = db.get(Setting, key)
if row:
row.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
class SmtpConfig(BaseModel):
host: str = ""
port: int = 587
user: str = ""
password: str = ""
from_addr: str = ""
use_tls: bool = True
class NotificationsConfig(BaseModel):
email_enabled: bool = False
smtp: SmtpConfig = Field(default_factory=SmtpConfig)
email_to: list[str] = Field(default_factory=list)
feishu_enabled: bool = False
feishu_webhook_url: str = ""
class TestRequest(BaseModel):
channel: str = Field(..., pattern="^(email|feishu)$")
to: str | None = None # 仅 email 通道有效
class TestResponse(BaseModel):
ok: bool
message: str
def _public_view(stored: dict) -> dict:
"""返回给前端:密码脱敏。"""
out = dict(stored)
if "smtp" in out and isinstance(out["smtp"], dict):
out["smtp"] = _redact(out["smtp"])
if _SENSITIVE.intersection(out.keys()):
out = _redact(out)
return out
@router.get("/notifications", response_model=dict)
def get_notifications(db: DBSession, _: AdminUser) -> dict:
stored = _load(db, _NOTIFY_KEY)
return _public_view(stored)
@router.put("/notifications", response_model=dict)
def put_notifications(payload: NotificationsConfig, db: DBSession, _: AdminUser) -> dict:
stored = _load(db, _NOTIFY_KEY)
incoming = payload.model_dump()
# 处理 smtp.password 脱敏占位
smtp_in = incoming.get("smtp") or {}
smtp_stored = stored.get("smtp") or {}
if smtp_in.get("password") == "***" and smtp_stored.get("password"):
smtp_in["password"] = smtp_stored["password"]
incoming["smtp"] = smtp_in
# 处理 webhook_url 脱敏
if incoming.get("feishu_webhook_url") == "***":
incoming["feishu_webhook_url"] = stored.get("feishu_webhook_url", "")
_save(db, _NOTIFY_KEY, encrypt_dict(incoming))
return _public_view(incoming)
@router.post("/notifications/test", response_model=TestResponse)
async def test_notification(payload: TestRequest, db: DBSession, _: AdminUser) -> TestResponse:
from app.core.notify import NotificationContext, send_email, send_feishu
stored = _load(db, _NOTIFY_KEY)
if not stored:
raise HTTPException(status_code=400, detail="通知配置未设置")
ctx = NotificationContext(
job_name="test-job",
job_type="mysql",
status="success",
run_id=0,
artifact_size=12345,
duration_seconds=3,
started_at="2026-06-22T10:00:00Z",
finished_at="2026-06-22T10:00:03Z",
)
if payload.channel == "email":
if not stored.get("email_enabled"):
raise HTTPException(status_code=400, detail="邮件通知未启用")
to_list = [payload.to] if payload.to else stored.get("email_to") or []
ok, msg = await send_email(
stored.get("smtp") or {},
to_list,
"[备份系统] 测试邮件",
"这是一封来自备份管理系统的测试邮件。",
)
return TestResponse(ok=ok, message=msg)
else: # feishu
if not stored.get("feishu_enabled"):
raise HTTPException(status_code=400, detail="飞书通知未启用")
ok, msg = await send_feishu(stored.get("feishu_webhook_url", ""), ctx)
return TestResponse(ok=ok, message=msg)
@router.get("", response_model=list[dict[str, Any]])
def list_all(db: DBSession, _: AdminUser) -> list[dict[str, Any]]:
rows = db.query(Setting).order_by(Setting.key).all()
return [{"key": r.key, "value": r.value[:200] + ("..." if len(r.value) > 200 else "")} for r in rows]
+117
View File
@@ -0,0 +1,117 @@
"""ETCD 备份:通过 etcdctl snapshot save 生成快照文件,再流式上传。
配置:
- endpoints:列表,如 ["http://10.0.0.1:2379", "http://10.0.0.2:2379"]
- username/password:可选
- cacert/cert/key:可选 TLS
"""
import asyncio
import shutil
import tarfile
import tempfile
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 EtcdBackup(BaseBackup):
def __init__(self, source_config: dict, job_name: str):
super().__init__(source_config, job_name)
# endpoints 接收 string(逗号分隔)或 list
eps = source_config.get("endpoints", "")
if isinstance(eps, str):
self.endpoints: list[str] = [e.strip() for e in eps.split(",") if e.strip()]
else:
self.endpoints = list(eps)
if not self.endpoints:
raise ValueError("ETCD endpoints 不能为空")
self.username: str = source_config.get("username", "")
self.password: str = source_config.get("password", "")
self.cacert: str = source_config.get("cacert", "")
self.cert: str = source_config.get("cert", "")
self.key: str = source_config.get("key", "")
def metadata(self) -> BackupMetadata:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
return BackupMetadata(
suggested_filename=f"{self.job_name}-etcd-{ts}.tar.gz",
content_type="application/gzip",
extra={
"endpoints": self.endpoints,
"has_tls": bool(self.cacert or self.cert or self.key),
"has_auth": bool(self.username),
},
)
async def produce(self) -> AsyncIterator[bytes]:
tmpdir = Path(tempfile.mkdtemp(prefix=f"etcdbk_{self.job_name}_"))
snap_path = tmpdir / "etcd.snap"
try:
cmd = ["etcdctl", "snapshot", "save", str(snap_path)]
# 多次 --endpoints 让 etcdctl 选择健康节点
for ep in self.endpoints:
cmd.extend(["--endpoints", ep])
if self.username:
cmd.extend(["--user", f"{self.username}:{self.password}"])
if self.cacert:
cmd.extend(["--cacert", self.cacert])
if self.cert:
cmd.extend(["--cert", self.cert])
if self.key:
cmd.extend(["--key", self.key])
log.info("Running: etcdctl snapshot save (endpoints=%s)", self.endpoints)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"etcdctl snapshot save failed (exit={proc.returncode}): "
f"{(stderr or stdout).decode(errors='replace')[:2000]}"
)
if not snap_path.exists() or snap_path.stat().st_size == 0:
raise RuntimeError("etcd snapshot 文件为空,请检查 ETCD 连接")
# 写入 metadata 文件并 tar.gz 打包
log.info("Snapshot size: %s bytes; packing...", snap_path.stat().st_size)
meta_path = tmpdir / "META.txt"
meta_path.write_text(
f"etcd_endpoints={','.join(self.endpoints)}\n"
f"backup_time={datetime.utcnow().isoformat()}Z\n"
f"snapshot_size={snap_path.stat().st_size}\n"
f"restore_hint=etcdctl snapshot restore <file> "
f"--name <name> --initial-cluster <cluster> --initial-advertise-peer-urls <url>\n",
encoding="utf-8",
)
tar_path = tmpdir / "backup.tar.gz"
loop = asyncio.get_event_loop()
def _tar() -> None:
with tarfile.open(str(tar_path), mode="w:gz") as tar:
tar.add(str(snap_path), arcname="etcd.snap")
tar.add(str(meta_path), arcname="META.txt")
await loop.run_in_executor(None, _tar)
# 流式产出
with open(tar_path, "rb") as f:
while True:
chunk = await loop.run_in_executor(None, f.read, 64 * 1024)
if not chunk:
break
yield chunk
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+60
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core.backup.base import BaseBackup
from app.core.backup.directory import DirectoryBackup
from app.core.backup.etcd import EtcdBackup
from app.core.backup.influxdb import InfluxDBBackup
from app.core.backup.mysql import MySQLBackup
from app.core.retention import apply_retention
@@ -35,6 +36,8 @@ def _make_backup(job: Job) -> BaseBackup:
return DirectoryBackup(src, job.name)
if job.type == "influxdb":
return InfluxDBBackup(src, job.name)
if job.type == "etcd":
return EtcdBackup(src, job.name)
raise ValueError(f"Unsupported job type: {job.type}")
@@ -127,6 +130,63 @@ async def run_backup(run_id: int, trigger: str = "manual") -> None:
db.add(run_log)
db.commit()
log.info("Backup run_id=%s finished status=%s", run_id, run.status)
# 触发通知(在事务外,避免阻塞 executorfire and forget
try:
should_notify = (
job.notify_on == "all"
or (job.notify_on == "failure" and run.status == "failed")
)
if should_notify:
asyncio.create_task(_dispatch_notification(job.id, run.id))
except Exception as e:
log.warning("Failed to schedule notification: %s", e)
finally:
db.close()
async def _dispatch_notification(job_id: int, run_id: int) -> None:
"""从 DB 重新加载最新 run 状态后发通知。"""
from app.core.notify import NotificationContext, send_notifications
from app.models.setting import Setting
from app.utils.crypto import decrypt_dict
db = SessionLocal()
try:
job = db.get(Job, job_id)
run = db.get(Run, run_id)
if not job or not run:
return
notify_row = db.get(Setting, "notifications")
if not notify_row or not notify_row.value:
return
try:
settings = decrypt_dict(notify_row.value)
except Exception:
return
if not (settings.get("email_enabled") or settings.get("feishu_enabled")):
return
ctx = NotificationContext(
job_name=job.name,
job_type=job.type,
status=run.status,
run_id=run.id,
artifact_size=run.artifact_size,
duration_seconds=run.duration_seconds,
error_message=run.error_message,
artifact_path=run.artifact_path,
started_at=run.started_at.isoformat() if run.started_at else None,
finished_at=run.finished_at.isoformat() if run.finished_at else None,
)
result = await send_notifications(settings, ctx)
log.info(
"Notification dispatched for run_id=%s: email_ok=%s feishu_ok=%s",
run.id, result.email_ok, result.feishu_ok,
)
finally:
db.close()
+217
View File
@@ -0,0 +1,217 @@
"""通知模块:邮件 (SMTP) + 飞书群机器人 (Webhook)。
设计:纯函数式 + 异步,调用方传入配置 dict 与上下文。
不依赖数据库或 settings 表(settings 读取由调用方负责)。
"""
import asyncio
import smtplib
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Iterable, Optional
import httpx
from app.utils.logging import get_logger
log = get_logger(__name__)
@dataclass
class NotificationContext:
job_name: str
job_type: str
status: str # "success" / "failed"
run_id: int
artifact_size: Optional[int] = None
duration_seconds: Optional[int] = None
error_message: Optional[str] = None
artifact_path: Optional[str] = None
started_at: Optional[str] = None
finished_at: Optional[str] = None
def _human_size(n: Optional[int]) -> str:
if n is None:
return "-"
if n < 1024:
return f"{n} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} KB"
if n < 1024 * 1024 * 1024:
return f"{n / 1024 / 1024:.1f} MB"
return f"{n / 1024 / 1024 / 1024:.2f} GB"
def _build_text(ctx: NotificationContext) -> str:
lines = [
f"[备份系统] {ctx.job_name}",
f"状态: {ctx.status.upper()}",
f"类型: {ctx.job_type}",
f"Run ID: {ctx.run_id}",
]
if ctx.started_at:
lines.append(f"开始: {ctx.started_at}")
if ctx.finished_at:
lines.append(f"结束: {ctx.finished_at}")
if ctx.duration_seconds is not None:
lines.append(f"耗时: {ctx.duration_seconds}s")
if ctx.artifact_size is not None:
lines.append(f"大小: {_human_size(ctx.artifact_size)}")
if ctx.artifact_path:
lines.append(f"路径: {ctx.artifact_path}")
if ctx.error_message:
lines.append(f"错误: {ctx.error_message[:1000]}")
return "\n".join(lines)
def _build_feishu_card(ctx: NotificationContext) -> dict:
color = "green" if ctx.status == "success" else "red"
fields = [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**类型**\n{ctx.job_type}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{ctx.run_id}"}},
]
if ctx.duration_seconds is not None:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{ctx.duration_seconds}s"}})
if ctx.artifact_size is not None:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**大小**\n{_human_size(ctx.artifact_size)}"}})
if ctx.error_message:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**错误**\n{ctx.error_message[:1000]}"}})
return {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"[备份] {ctx.job_name}{ctx.status.upper()}"},
"template": color,
},
"elements": [
{"tag": "div", "fields": fields},
{"tag": "hr"},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"Backup System • {ctx.started_at or ''}"}
],
},
],
},
}
# ===== Email =====
async def send_email(
smtp_config: dict,
to_addrs: Iterable[str],
subject: str,
body: str,
) -> tuple[bool, str]:
"""通过 SMTP 发送邮件。smtp_config 必填字段:
host, port, user, password, from_addr, use_tls (bool)
"""
host = smtp_config.get("host")
if not host:
return False, "SMTP host 未配置"
port = int(smtp_config.get("port") or 587)
user = smtp_config.get("user", "")
password = smtp_config.get("password", "")
from_addr = smtp_config.get("from_addr") or user
use_tls = bool(smtp_config.get("use_tls", True))
to_list = [a.strip() for a in to_addrs if a and a.strip()]
if not to_list:
return False, "收件人列表为空"
msg = MIMEMultipart()
msg["From"] = from_addr
msg["To"] = ", ".join(to_list)
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
def _send() -> tuple[bool, str]:
try:
if use_tls:
client = smtplib.SMTP(host, port, timeout=15)
client.ehlo()
client.starttls()
client.ehlo()
else:
client = smtplib.SMTP_SSL(host, port, timeout=15)
client.ehlo()
if user:
client.login(user, password)
client.sendmail(from_addr, to_list, msg.as_string())
client.quit()
return True, f"sent to {len(to_list)} recipient(s)"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
ok, msg_text = await asyncio.get_event_loop().run_in_executor(None, _send)
return ok, msg_text
# ===== Feishu Webhook =====
async def send_feishu(webhook_url: str, ctx: NotificationContext) -> tuple[bool, str]:
"""飞书自定义机器人:发送 interactive card。"""
if not webhook_url:
return False, "webhook URL 未配置"
payload = _build_feishu_card(ctx)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(webhook_url, json=payload)
if resp.status_code != 200:
return False, f"HTTP {resp.status_code}: {resp.text[:300]}"
data = resp.json()
if data.get("code") not in (0, None):
return False, f"feishu err: {data.get('msg') or data}"
return True, "ok"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# ===== Aggregator =====
@dataclass
class NotifyResult:
email_ok: bool = True
email_msg: str = ""
feishu_ok: bool = True
feishu_msg: str = ""
async def send_notifications(
notify_settings: dict,
ctx: NotificationContext,
) -> NotifyResult:
"""根据 settings 发送通知。settings 结构:
{
"email_enabled": true,
"smtp": {"host", "port", "user", "password", "from_addr", "use_tls"},
"email_to": ["ops@example.com", ...],
"feishu_enabled": true,
"feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/...",
}
"""
res = NotifyResult()
if notify_settings.get("email_enabled"):
smtp = notify_settings.get("smtp") or {}
to_list = notify_settings.get("email_to") or []
subject = f"[{'成功' if ctx.status == 'success' else '失败'}] 备份任务 {ctx.job_name}"
ok, msg = await send_email(smtp, to_list, subject, _build_text(ctx))
res.email_ok = ok
res.email_msg = msg
log.info("Email notify: ok=%s msg=%s", ok, msg)
if notify_settings.get("feishu_enabled"):
url = notify_settings.get("feishu_webhook_url", "")
ok, msg = await send_feishu(url, ctx)
res.feishu_ok = ok
res.feishu_msg = msg
log.info("Feishu notify: ok=%s msg=%s", ok, msg)
return res
+3
View File
@@ -25,6 +25,9 @@ class Job(Base):
)
cron_expression: Mapped[str | None] = mapped_column(String(64), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
notify_on: Mapped[str] = mapped_column(
String(16), nullable=False, default="none"
) # 'none' / 'failure' / 'all'
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)
+17 -1
View File
@@ -41,13 +41,27 @@ class InfluxDBSourceConfigV2(BaseModel):
token: str = Field(..., min_length=1)
class EtcdSourceConfig(BaseModel):
endpoints: str = Field(
...,
min_length=1,
description="ETCD endpoints,逗号分隔,如 http://10.0.0.1:2379,http://10.0.0.2:2379",
)
username: str = Field("", description="可选 basic auth 用户名")
password: str = Field("")
cacert: str = Field("", description="CA 证书路径(容器内)")
cert: str = Field("", description="客户端证书路径")
key: str = Field("", description="客户端私钥路径")
class JobBase(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
type: Literal["mysql", "directory", "influxdb"]
type: Literal["mysql", "directory", "influxdb", "etcd"]
source_config: dict
storage_id: int
cron_expression: Optional[str] = Field(None, description="标准 5 段 cron 表达式,空表示手动")
enabled: bool = True
notify_on: Literal["none", "failure", "all"] = Field("none", description="何时发通知")
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)
@@ -74,6 +88,7 @@ class JobUpdate(BaseModel):
storage_id: Optional[int] = None
cron_expression: Optional[str] = None
enabled: Optional[bool] = None
notify_on: Optional[Literal["none", "failure", "all"]] = 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
@@ -86,6 +101,7 @@ class JobOut(BaseModel):
type: str
cron_expression: Optional[str]
enabled: bool
notify_on: str
retention_count: int
retention_days: Optional[int]
description: Optional[str]