新增备份监控与定时配置
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
|
||||
export interface BackupSchedule {
|
||||
enabled: boolean
|
||||
full_hour: number
|
||||
full_minute: number
|
||||
binlog_interval_minutes: number
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
generated_at: string
|
||||
health: 'healthy' | 'warning'
|
||||
last_full: { local_complete: string; remote_complete: string }
|
||||
last_binlog: { file: string; remote_dir: string }
|
||||
last_job: { type: string; status: string; at: string; message: string }
|
||||
}
|
||||
|
||||
export async function fetchBackupStatus() {
|
||||
const { data } = await apiClient.get<ApiResponse<BackupStatus>>('/admin/backups/status')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchBackupSchedule() {
|
||||
const { data } = await apiClient.get<ApiResponse<BackupSchedule>>('/admin/backups/schedule')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateBackupSchedule(payload: BackupSchedule) {
|
||||
const { data } = await apiClient.put<ApiResponse<BackupSchedule>>('/admin/backups/schedule', payload)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
fetchBackupSchedule,
|
||||
fetchBackupStatus,
|
||||
updateBackupSchedule,
|
||||
type BackupStatus,
|
||||
} from '@/features/admin/api/backup'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
|
||||
const adminSession = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const status = ref<BackupStatus | null>(null)
|
||||
const statusError = ref('')
|
||||
const canUpdateSchedule = computed(() => adminSession.isSuperAdmin || adminSession.hasPermission('backup:schedule:update'))
|
||||
const schedule = reactive({ enabled: true, full_hour: 4, full_minute: 30, binlog_interval_minutes: 1 })
|
||||
|
||||
const healthLabel = computed(() => (status.value?.health === 'healthy' ? '链路正常' : '需要检查'))
|
||||
const healthType = computed(() => (status.value?.health === 'healthy' ? 'success' : 'warning'))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
statusError.value = ''
|
||||
try {
|
||||
const [nextStatus, nextSchedule] = await Promise.all([fetchBackupStatus(), fetchBackupSchedule()])
|
||||
status.value = nextStatus
|
||||
Object.assign(schedule, nextSchedule)
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : '无法读取备份状态'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
saving.value = true
|
||||
try {
|
||||
Object.assign(schedule, await updateBackupSchedule({ ...schedule }))
|
||||
ElMessage.success('定时配置已保存,宿主机调度器将在下一分钟读取')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="backup-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>数据备份</h1>
|
||||
</div>
|
||||
<el-button :loading="loading" circle title="刷新状态" @click="load">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="statusError" type="warning" :title="statusError" :closable="false" show-icon />
|
||||
|
||||
<div v-else class="status-grid">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>备份健康状态</span></template>
|
||||
<el-tag :type="healthType" effect="light">{{ healthLabel }}</el-tag>
|
||||
<dl>
|
||||
<dt>状态更新时间</dt>
|
||||
<dd>{{ status?.generated_at ? formatDateTime(status.generated_at) : '-' }}</dd>
|
||||
<dt>最近任务</dt>
|
||||
<dd>{{ status?.last_job.type || '-' }} {{ status?.last_job.status || '' }}</dd>
|
||||
<dt>任务时间</dt>
|
||||
<dd>{{ status?.last_job.at ? formatDateTime(status.last_job.at) : '-' }}</dd>
|
||||
</dl>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>最新全量备份</span></template>
|
||||
<dl>
|
||||
<dt>本地完成标识</dt>
|
||||
<dd class="path">{{ status?.last_full.local_complete || '-' }}</dd>
|
||||
<dt>OSS 完成标识</dt>
|
||||
<dd class="path">{{ status?.last_full.remote_complete || '-' }}</dd>
|
||||
</dl>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>最新 Binlog 归档</span></template>
|
||||
<dl>
|
||||
<dt>Binlog 文件</dt>
|
||||
<dd>{{ status?.last_binlog.file || '-' }}</dd>
|
||||
<dt>归档对象</dt>
|
||||
<dd class="path">{{ status?.last_binlog.remote_dir || '-' }}</dd>
|
||||
</dl>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="schedule-card">
|
||||
<template #header><span>定时配置</span></template>
|
||||
<el-form label-position="top" class="schedule-form">
|
||||
<el-form-item label="启用服务器定时备份">
|
||||
<el-switch v-model="schedule.enabled" :disabled="!canUpdateSchedule" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每日全量备份时间(Asia/Shanghai)">
|
||||
<div class="time-inputs">
|
||||
<el-input-number v-model="schedule.full_hour" :min="0" :max="23" :disabled="!canUpdateSchedule" controls-position="right" />
|
||||
<span>:</span>
|
||||
<el-input-number v-model="schedule.full_minute" :min="0" :max="59" :disabled="!canUpdateSchedule" controls-position="right" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="Binlog 归档间隔">
|
||||
<el-select v-model="schedule.binlog_interval_minutes" :disabled="!canUpdateSchedule">
|
||||
<el-option :value="1" label="每 1 分钟" />
|
||||
<el-option :value="2" label="每 2 分钟" />
|
||||
<el-option :value="3" label="每 3 分钟" />
|
||||
<el-option :value="5" label="每 5 分钟" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button v-if="canUpdateSchedule" type="primary" :loading="saving" @click="saveSchedule">保存定时配置</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-page { padding: 20px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; }
|
||||
.page-header h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
dl { margin: 16px 0 0; display: grid; gap: 8px; }
|
||||
dt { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.path { color: var(--el-text-color-regular); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.schedule-card { margin-top: 16px; }
|
||||
.schedule-form { display: grid; grid-template-columns: repeat(3, minmax(180px, 260px)); gap: 0 18px; align-items: end; }
|
||||
.time-inputs { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 900px) { .status-grid, .schedule-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user