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:
publ
2026-06-22 18:27:41 +08:00
commit 4e3a4b4602
92 changed files with 5290 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=/api
+18
View File
@@ -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;"]
+13
View File
@@ -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>
+43
View File
@@ -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";
}
}
+29
View File
@@ -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"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { BrowserRouter } from 'react-router-dom'
import { AppRouter } from './router'
export default function App() {
return (
<BrowserRouter>
<AppRouter />
</BrowserRouter>
)
}
+17
View File
@@ -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),
}
+45
View File
@@ -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)
},
)
+21
View File
@@ -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),
}
+17
View File
@@ -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),
}
+11
View File
@@ -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),
}
+19
View File
@@ -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),
}
+128
View File
@@ -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}</>
}
+6
View File
@@ -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 }
}
+27
View File
@@ -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>,
)
+192
View File
@@ -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>
)
}
+147
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { JobForm } from './JobForm'
export function JobCreate() {
return <JobForm mode="create" />
}
+5
View File
@@ -0,0 +1,5 @@
import { JobForm } from './JobForm'
export function JobEdit() {
return <JobForm mode="edit" />
}
+268
View File
@@ -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>
)
}
+146
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+217
View File
@@ -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>
)
}
+135
View File
@@ -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>
)
}
+79
View File
@@ -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>
)
}
+259
View File
@@ -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>
)
}
+41
View File
@@ -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>
)
}
+51
View File
@@ -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 }),
},
),
)
+11
View File
@@ -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 })),
}))
+181
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+27
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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,
},
})