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]
+16
View File
@@ -0,0 +1,16 @@
import { api } from './client'
import type { NotificationsConfig } from '../types'
export const settingsApi = {
getNotifications: () =>
api.get<NotificationsConfig>('/settings/notifications').then((r) => r.data),
updateNotifications: (payload: NotificationsConfig) =>
api.put<NotificationsConfig>('/settings/notifications', payload).then((r) => r.data),
test: (channel: 'email' | 'feishu', to?: string) =>
api
.post<{ ok: boolean; message: string }>('/settings/notifications/test', {
channel,
to,
})
.then((r) => r.data),
}
+71 -3
View File
@@ -22,10 +22,11 @@ const { Title } = Typography
interface FormValues {
name: string
type: 'mysql' | 'directory' | 'influxdb'
type: 'mysql' | 'directory' | 'influxdb' | 'etcd'
storage_id: number
enabled: boolean
cron_expression?: string
notify_on?: 'none' | 'failure' | 'all'
retention_count: number
retention_days?: number
description?: string
@@ -50,6 +51,13 @@ interface FormValues {
influx_org?: string
influx_bucket?: string
influx_token?: string
// ETCD
etcd_endpoints?: string
etcd_username?: string
etcd_password?: string
etcd_cacert?: string
etcd_cert?: string
etcd_key?: string
}
function toPayload(values: FormValues): JobCreate {
@@ -71,8 +79,7 @@ function toPayload(values: FormValues): JobCreate {
? values.dir_exclude_patterns.split(',').map((s) => s.trim()).filter(Boolean)
: [],
}
} else {
// influxdb
} else if (values.type === 'influxdb') {
if (values.influx_version === '1') {
source_config = {
version: '1',
@@ -92,6 +99,16 @@ function toPayload(values: FormValues): JobCreate {
token: values.influx_token!,
}
}
} else {
// etcd
source_config = {
endpoints: values.etcd_endpoints!,
username: values.etcd_username || '',
password: values.etcd_password || '',
cacert: values.etcd_cacert || '',
cert: values.etcd_cert || '',
key: values.etcd_key || '',
}
}
return {
@@ -100,6 +117,7 @@ function toPayload(values: FormValues): JobCreate {
storage_id: values.storage_id,
source_config,
enabled: values.enabled,
notify_on: values.notify_on || 'none',
cron_expression: values.cron_expression?.trim() || undefined,
retention_count: values.retention_count,
retention_days: values.retention_days,
@@ -167,6 +185,16 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
influx_bucket: cfg.bucket,
influx_token: cfg.token,
}
} else if (j.type === 'etcd') {
flat = {
...flat,
etcd_endpoints: cfg.endpoints,
etcd_username: cfg.username,
etcd_password: cfg.password,
etcd_cacert: cfg.cacert,
etcd_cert: cfg.cert,
etcd_key: cfg.key,
}
}
form.setFieldsValue(flat as FormValues)
})
@@ -204,6 +232,7 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
initialValues={{
type: 'mysql',
enabled: true,
notify_on: 'none',
retention_count: 7,
mysql_port: 3306,
}}
@@ -224,6 +253,7 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
{ value: 'mysql', label: 'MySQL 数据库' },
{ value: 'directory', label: '服务器目录' },
{ value: 'influxdb', label: 'InfluxDB 数据库' },
{ value: 'etcd', label: 'ETCD' },
]}
/>
</Form.Item>
@@ -326,6 +356,30 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
</>
)}
{jobType === 'etcd' && (
<>
<Divider orientation="left">ETCD </Divider>
<Form.Item name="etcd_endpoints" label="Endpoints" rules={[{ required: jobType === 'etcd' }]}>
<Input placeholder="http://10.0.0.1:2379,http://10.0.0.2:2379" />
</Form.Item>
<Form.Item name="etcd_username" label="用户名(可选)">
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="etcd_password" label="密码(可选)">
<Input.Password placeholder="留空表示无密码" />
</Form.Item>
<Form.Item name="etcd_cacert" label="CA 证书路径(可选,容器内路径)" extra="如 /etc/etcd/ca.crt">
<Input />
</Form.Item>
<Form.Item name="etcd_cert" label="客户端证书(可选)">
<Input />
</Form.Item>
<Form.Item name="etcd_key" label="客户端私钥(可选)">
<Input />
</Form.Item>
</>
)}
<Divider orientation="left"></Divider>
<Form.Item name="storage_id" label="存储目标" rules={[{ required: true, message: '请选择存储目标' }]}>
@@ -347,6 +401,20 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
<Switch />
</Form.Item>
<Form.Item
name="notify_on"
label="通知策略"
extra="需要在「设置 → 通知」中先配置 SMTP 和/或飞书 Webhook"
>
<Select
options={[
{ value: 'none', label: '不通知' },
{ value: 'failure', label: '仅失败时' },
{ value: 'all', label: '成功 + 失败' },
]}
/>
</Form.Item>
<Divider orientation="left"></Divider>
<Space size="large">
+14
View File
@@ -101,6 +101,20 @@ export function JobList() {
<Switch checked={e} size="small" onChange={() => onToggle(r.id)} />
),
},
{
title: '通知',
dataIndex: 'notify_on',
width: 90,
render: (n: string) => {
const map: Record<string, { color: string; text: string }> = {
none: { color: 'default', text: '关闭' },
failure: { color: 'orange', text: '失败' },
all: { color: 'blue', text: '全部' },
}
const m = map[n] || map.none
return <Tag color={m.color}>{m.text}</Tag>
},
},
{
title: '上次状态',
width: 100,
+245 -5
View File
@@ -1,15 +1,71 @@
import { useState } from 'react'
import { Button, Card, Form, Input, Typography, App, Space } from 'antd'
import { useEffect, useState } from 'react'
import {
Alert,
Button,
Card,
Collapse,
Divider,
Form,
Input,
InputNumber,
Space,
Switch,
Typography,
App,
} from 'antd'
import { useAuth } from '../hooks/useAuth'
import { authApi } from '../api/auth'
import { settingsApi } from '../api/settings'
import type { NotificationsConfig } from '../types'
const { Title } = Typography
const DEFAULT_NOTIFY: NotificationsConfig = {
email_enabled: false,
smtp: { host: '', port: 587, user: '', password: '', from_addr: '', use_tls: true },
email_to: [],
feishu_enabled: false,
feishu_webhook_url: '',
}
// 表单内部使用:email_to_text 是 textarea 的字符串版本,与后端的 email_to 互转
type NotificationsFormValues = Omit<NotificationsConfig, 'email_to'> & {
email_to_text?: string
}
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 [pwForm] = Form.useForm<{ old_password: string; new_password: string; confirm: string }>()
const [notifyForm] = Form.useForm<NotificationsFormValues>()
const [notifyLoading, setNotifyLoading] = useState(false)
const [notify, setNotify] = useState<NotificationsConfig>(DEFAULT_NOTIFY)
const [testingEmail, setTestingEmail] = useState(false)
const [testingFeishu, setTestingFeishu] = useState(false)
useEffect(() => {
settingsApi
.getNotifications()
.then((cfg) => {
// email_to 是数组;表单里用 textarea 拆行,存为字符串字段
const normalized: any = {
...DEFAULT_NOTIFY,
...cfg,
smtp: { ...DEFAULT_NOTIFY.smtp, ...(cfg.smtp || {}) },
email_to: Array.isArray(cfg.email_to) ? cfg.email_to : [],
}
setNotify(normalized)
notifyForm.setFieldsValue({
...normalized,
email_to_text: normalized.email_to.join('\n'),
})
})
.catch(() => {
// 没有配置时使用默认值
notifyForm.setFieldsValue({ ...DEFAULT_NOTIFY, email_to_text: '' })
})
}, [])
const onChangePassword = async (v: { old_password: string; new_password: string; confirm: string }) => {
if (v.new_password !== v.confirm) {
@@ -31,6 +87,71 @@ export function Settings() {
}
}
const onSaveNotify = async () => {
try {
const v = await notifyForm.validateFields()
const payload: NotificationsConfig = {
email_enabled: !!v.email_enabled,
smtp: {
host: v.smtp?.host || '',
port: v.smtp?.port || 587,
user: v.smtp?.user || '',
// password 字段为 "***" 时后端保留原值
password: v.smtp?.password || '',
from_addr: v.smtp?.from_addr || '',
use_tls: v.smtp?.use_tls ?? true,
},
email_to: ((v.email_to_text as string) || '')
.split('\n')
.map((s: string) => s.trim())
.filter(Boolean),
feishu_enabled: !!v.feishu_enabled,
// webhook_url 同样支持 "***" 占位
feishu_webhook_url: v.feishu_webhook_url || '',
}
setNotifyLoading(true)
const saved = await settingsApi.updateNotifications(payload)
const norm = {
...DEFAULT_NOTIFY,
...saved,
smtp: { ...DEFAULT_NOTIFY.smtp, ...(saved.smtp || {}) },
email_to: Array.isArray(saved.email_to) ? saved.email_to : [],
}
setNotify(norm)
notifyForm.setFieldsValue({
...norm,
email_to_text: norm.email_to.join('\n'),
})
message.success('通知配置已保存')
} catch {
/* */
} finally {
setNotifyLoading(false)
}
}
const onTestEmail = async () => {
setTestingEmail(true)
try {
const r = await settingsApi.test('email')
if (r.ok) message.success(r.message)
else message.error(r.message)
} finally {
setTestingEmail(false)
}
}
const onTestFeishu = async () => {
setTestingFeishu(true)
try {
const r = await settingsApi.test('feishu')
if (r.ok) message.success(r.message)
else message.error(r.message)
} finally {
setTestingFeishu(false)
}
}
return (
<div>
<Title level={3}></Title>
@@ -41,9 +162,9 @@ export function Settings() {
<p>{user?.created_at && new Date(user.created_at).toLocaleString()}</p>
</Card>
<Card title="修改密码">
<Card title="修改密码" style={{ marginBottom: 16 }}>
<Form
form={form}
form={pwForm}
layout="vertical"
onFinish={onChangePassword}
style={{ maxWidth: 480 }}
@@ -74,6 +195,125 @@ export function Settings() {
</Form.Item>
</Form>
</Card>
<Card title="通知配置(邮件 + 飞书机器人)">
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="通知由系统全局配置;任务级别通过「通知策略」开关决定何时触发(无/失败时/始终)"
/>
<Form form={notifyForm} layout="vertical" style={{ maxWidth: 720 }}>
<Collapse
defaultActiveKey={['email']}
items={[
{
key: 'email',
label: '邮件(SMTP',
children: (
<>
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() =>
((notifyForm.getFieldValue('email_enabled') as boolean) || false) ? (
<>
<Divider orientation="left" plain>SMTP </Divider>
<Form.Item name={['smtp', 'host']} label="Host">
<Input placeholder="smtp.example.com" />
</Form.Item>
<Space size="middle">
<Form.Item name={['smtp', 'port']} label="Port">
<InputNumber min={1} max={65535} />
</Form.Item>
<Form.Item name={['smtp', 'use_tls']} label="TLS/STARTTLS" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Space>
<Form.Item name={['smtp', 'user']} label="用户名">
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item
name={['smtp', 'password']}
label="密码"
extra="留空表示不变更;显示 *** 表示已设置"
>
<Input.Password
placeholder={
notify.smtp.password ? '已设置,留空表示不变更' : 'SMTP 密码'
}
/>
</Form.Item>
<Form.Item
name={['smtp', 'from_addr']}
label="发件人地址"
extra="留空时使用用户名"
>
<Input placeholder="backup@example.com" />
</Form.Item>
<Form.Item
name="email_to_text"
label="收件人列表"
extra="每行一个邮箱"
>
<Input.TextArea rows={3} placeholder="ops@example.com" />
</Form.Item>
<Button onClick={onTestEmail} loading={testingEmail}>
</Button>
</>
) : null
}
</Form.Item>
</>
),
},
{
key: 'feishu',
label: '飞书群机器人',
children: (
<>
<Form.Item name="feishu_enabled" label="启用飞书通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() =>
((notifyForm.getFieldValue('feishu_enabled') as boolean) || false) ? (
<>
<Form.Item
name="feishu_webhook_url"
label="Webhook URL"
extra="在飞书群机器人中添加自定义机器人,复制 webhook 地址粘贴于此"
>
<Input.Password
placeholder={
notify.feishu_webhook_url
? '已设置,留空表示不变更'
: 'https://open.feishu.cn/open-apis/bot/v2/hook/...'
}
/>
</Form.Item>
<Button onClick={onTestFeishu} loading={testingFeishu}>
</Button>
</>
) : null
}
</Form.Item>
</>
),
},
]}
/>
<Divider />
<Space>
<Button type="primary" onClick={onSaveNotify} loading={notifyLoading}>
</Button>
</Space>
</Form>
</Card>
</div>
)
}
+33 -4
View File
@@ -97,6 +97,32 @@ export interface InfluxDBSourceConfigV2 {
export type InfluxDBSourceConfig = InfluxDBSourceConfigV1 | InfluxDBSourceConfigV2
export interface EtcdSourceConfig {
endpoints: string
username?: string
password?: string
cacert?: string
cert?: string
key?: string
}
export interface SmtpConfig {
host: string
port: number
user: string
password?: string
from_addr: string
use_tls: boolean
}
export interface NotificationsConfig {
email_enabled: boolean
smtp: SmtpConfig
email_to: string[]
feishu_enabled: boolean
feishu_webhook_url: string
}
export interface RunSummary {
id: number
status: string
@@ -111,9 +137,10 @@ export interface RunSummary {
export interface JobOut {
id: number
name: string
type: 'mysql' | 'directory' | 'influxdb'
type: 'mysql' | 'directory' | 'influxdb' | 'etcd'
cron_expression: string | null
enabled: boolean
notify_on: 'none' | 'failure' | 'all'
retention_count: number
retention_days: number | null
description: string | null
@@ -129,11 +156,12 @@ export interface JobWithLastRun extends JobOut {
export interface JobCreate {
name: string
type: 'mysql' | 'directory' | 'influxdb'
source_config: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig
type: 'mysql' | 'directory' | 'influxdb' | 'etcd'
source_config: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig | EtcdSourceConfig
storage_id: number
cron_expression?: string
enabled?: boolean
notify_on?: 'none' | 'failure' | 'all'
retention_count?: number
retention_days?: number
description?: string
@@ -141,10 +169,11 @@ export interface JobCreate {
export interface JobUpdate {
name?: string
source_config?: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig
source_config?: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig | EtcdSourceConfig
storage_id?: number
cron_expression?: string | null
enabled?: boolean
notify_on?: 'none' | 'failure' | 'all'
retention_count?: number
retention_days?: number | null
description?: string | null