灾容datakeep项目推送

This commit is contained in:
2026-02-09 18:07:46 +08:00
commit c2ffb3c8b8
17 changed files with 836 additions and 0 deletions

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# build stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# run stage
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "datakeep-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^1.6.0",
"core-js": "^3.8.3",
"element-ui": "^2.15.14",
"vue": "^2.6.14"
},
"devDependencies": {
"@vue/cli-service": "~5.0.0",
"vue-template-compiler": "^2.6.14"
}
}

216
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<div id="app" style="padding: 20px; background-color: #f0f2f5; min-height: 100vh;">
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 20px; font-weight: bold;">DataKeep 容灾备份系统</span>
<el-button type="primary" icon="el-icon-plus" @click="showCreate">新增备份实例</el-button>
</div>
<el-table :data="instances" style="width: 100%">
<el-table-column prop="id" label="实例ID" width="120"></el-table-column>
<el-table-column prop="name" label="名称" width="180"></el-table-column>
<el-table-column label="同步时间" width="150">
<template slot-scope="scope">
每天 {{ scope.row.sync_hour }}:00
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button size="mini" type="success" @click="runInstance(scope.row.id)">立即同步</el-button>
<el-button size="mini" @click="showEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="info" @click="viewHistory(scope.row.id)">历史</el-button>
<el-button size="mini" type="danger" @click="deleteInstance(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 编辑/新增对话框 -->
<el-dialog :title="isEdit ? '编辑实例' : '新增实例'" :visible.sync="dialogVisible" width="70%">
<el-form :model="form" label-width="120px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实例ID"><el-input v-model="form.id" :disabled="isEdit"></el-input></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称"><el-input v-model="form.name"></el-input></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="每天同步时间">
<el-select v-model="form.sync_hour" placeholder="选择小时" style="width: 100%">
<el-option v-for="h in 24" :key="h-1" :label="`${h-1}:00`" :value="h-1"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否启用">
<el-switch v-model="form.enabled"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-tabs type="border-card" style="margin-top: 20px;">
<el-tab-pane label="MySQL (必选)">
<el-divider content-position="left">生产端 ()</el-divider>
<el-row :gutter="10">
<el-col :span="16"><el-form-item label="Host"><el-input v-model="form.prod_mysql.host"></el-input></el-form-item></el-col>
<el-col :span="8"><el-form-item label="Port"><el-input-number v-model="form.prod_mysql.port" :controls="false" style="width: 100%"></el-input-number></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8"><el-form-item label="User"><el-input v-model="form.prod_mysql.user"></el-input></el-form-item></el-col>
<el-col :span="8"><el-form-item label="Pass"><el-input v-model="form.prod_mysql.password" show-password></el-input></el-form-item></el-col>
<el-col :span="8"><el-form-item label="DB"><el-input v-model="form.prod_mysql.database"></el-input></el-form-item></el-col>
</el-row>
<el-divider content-position="left">备份端 (目标)</el-divider>
<el-row :gutter="10">
<el-col :span="16"><el-form-item label="Host"><el-input v-model="form.dr_mysql.host" placeholder="容器名: dr-mysql"></el-input></el-form-item></el-col>
<el-col :span="8"><el-form-item label="Port"><el-input-number v-model="form.dr_mysql.port" :controls="false" style="width: 100%"></el-input-number></el-form-item></el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="Redis (可选)">
<el-row :gutter="20">
<el-col :span="12">
<el-divider content-position="left">生产端 ()</el-divider>
<el-form-item label="Host"><el-input v-model="form.prod_redis.host"></el-input></el-form-item>
<el-form-item label="Port"><el-input-number v-model="form.prod_redis.port" :controls="false" style="width: 100%"></el-input-number></el-form-item>
<el-form-item label="Pass"><el-input v-model="form.prod_redis.password" show-password></el-input></el-form-item>
</el-col>
<el-col :span="12">
<el-divider content-position="left">备份端 (目标)</el-divider>
<el-form-item label="Host"><el-input v-model="form.dr_redis.host" placeholder="容器名: dr-redis"></el-input></el-form-item>
<el-form-item label="Port"><el-input-number v-model="form.dr_redis.port" :controls="false" style="width: 100%"></el-input-number></el-form-item>
<el-form-item label="Pass"><el-input v-model="form.dr_redis.password" show-password></el-input></el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="MinIO (可选)">
<el-divider content-position="left">连接配置</el-divider>
<el-row :gutter="10">
<el-col :span="12"><el-form-item label="生产端 Endpoint"><el-input v-model="form.prod_minio.endpoint"></el-input></el-form-item></el-col>
<el-col :span="12"><el-form-item label="备份端 Endpoint"><el-input v-model="form.dr_minio.endpoint" placeholder="容器名: dr-minio:9000"></el-input></el-form-item></el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12"><el-form-item label="Buckets (逗号分隔)"><el-input v-model="bucketsStr" placeholder="bucket1,bucket2"></el-input></el-form-item></el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</el-dialog>
<!-- 历史记录 -->
<el-drawer title="同步历史记录" :visible.sync="historyVisible" size="50%">
<div style="padding: 20px;">
<el-timeline>
<el-timeline-item v-for="run in runHistory" :key="run.id" :timestamp="run.started_at" :type="run.ok ? 'success' : 'danger'">
<el-card>
<h4>结果: {{ run.ok ? '成功' : '失败' }} (耗时: {{ calcDuration(run) }})</h4>
<div v-for="step in run.steps" :key="step.name">
<p><b>{{ step.name }}:</b> {{ step.ok ? 'OK' : 'ERROR: ' + step.error }} {{ step.count ? '(数量: ' + step.count + ')' : '' }}</p>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
</div>
</template>
<script>
export default {
data() {
return {
instances: [],
dialogVisible: false,
historyVisible: false,
isEdit: false,
runHistory: [],
bucketsStr: '',
form: {
id: '', name: '', enabled: true, sync_hour: 2,
prod_mysql: { host: '', port: 3306, user: '', password: '', database: '' },
dr_mysql: { host: '', port: 3306, user: '', password: '', database: '' },
prod_redis: { host: '', port: 6379, password: '' },
dr_redis: { host: '', port: 6379, password: '' },
prod_minio: { endpoint: '', access_key: '', secret_key: '', secure: false },
dr_minio: { endpoint: '', access_key: '', secret_key: '', secure: false },
minio_buckets: []
}
}
},
mounted() { this.refresh(); },
methods: {
async refresh() {
const res = await this.$http.get('/instances');
this.instances = res.data;
},
showCreate() {
this.isEdit = false;
this.resetForm();
this.dialogVisible = true;
},
showEdit(row) {
this.isEdit = true;
this.form = JSON.parse(JSON.stringify(row));
this.bucketsStr = (this.form.minio_buckets || []).join(',');
this.dialogVisible = true;
},
resetForm() {
this.form = {
id: '', name: '', enabled: true, sync_hour: 2,
prod_mysql: { host: '', port: 3306, user: '', password: '', database: '' },
dr_mysql: { host: '', port: 3306, user: '', password: '', database: '' },
prod_redis: { host: '', port: 6379, password: '' },
dr_redis: { host: '', port: 6379, password: '' },
prod_minio: { endpoint: '', access_key: '', secret_key: '', secure: false },
dr_minio: { endpoint: '', access_key: '', secret_key: '', secure: false },
minio_buckets: []
};
this.bucketsStr = '';
},
async submitForm() {
this.form.minio_buckets = this.bucketsStr ? this.bucketsStr.split(',').map(s => s.trim()) : [];
if (this.isEdit) {
await this.$http.put(`/instances/${this.form.id}`, this.form);
} else {
await this.$http.post('/instances', this.form);
}
this.dialogVisible = false;
this.refresh();
this.$message.success('保存成功');
},
async runInstance(id) {
await this.$http.post(`/instances/${id}/run`);
this.$message.success('已触发同步任务');
},
async deleteInstance(id) {
await this.$confirm('确定删除此实例吗?');
await this.$http.delete(`/instances/${id}`);
this.refresh();
},
async viewHistory(id) {
const res = await this.$http.get(`/instances/${id}/runs`);
this.runHistory = res.data;
this.historyVisible = true;
},
calcDuration(run) {
if (!run.finished_at) return '进行中';
const d = new Date(run.finished_at) - new Date(run.started_at);
return Math.round(d / 1000) + 's';
}
}
}
</script>

21
frontend/src/main.js Normal file
View File

@@ -0,0 +1,21 @@
import Vue from 'vue'
import App from './App.vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import axios from 'axios'
Vue.use(ElementUI)
Vue.config.productionTip = false
// 创建 axios 实例
const http = axios.create({
baseURL: process.env.VUE_APP_API_BASE || '/api',
timeout: 10000
})
Vue.prototype.$http = http
new Vue({
render: h => h(App),
}).$mount('#app')

12
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
},
transpileDependencies: true
}