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
+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