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:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AppRouter } from './router'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JobForm } from './JobForm'
|
||||
|
||||
export function JobCreate() {
|
||||
return <JobForm mode="create" />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JobForm } from './JobForm'
|
||||
|
||||
export function JobEdit() {
|
||||
return <JobForm mode="edit" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -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 })),
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user