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:
@@ -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),
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user