mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
feat(admin): expose s3 backup as module
This commit is contained in:
@@ -6,4 +6,5 @@ pub(crate) mod store;
|
||||
pub(crate) mod task;
|
||||
pub(crate) mod worker;
|
||||
|
||||
pub(crate) const S3_BACKUP_ENABLED_KEY: &str = "backup_s3_enabled";
|
||||
pub(crate) const S3_BACKUP_LAST_SLOT_KEY: &str = "backup_s3_last_slot";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::backup::config::S3BackupConfig;
|
||||
use crate::bark_push::bark_push_configured;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::{module_available_from_env, system_config_bool};
|
||||
@@ -121,6 +122,18 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
||||
admin_menu_group: None,
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "s3_backup",
|
||||
display_name: "S3 备份",
|
||||
description: "将配置、用户或完整数据定期备份到 S3-compatible 对象存储",
|
||||
category: "integration",
|
||||
env_key: "S3_BACKUP_AVAILABLE",
|
||||
default_available: true,
|
||||
admin_route: Some("/admin/modules/s3-backup"),
|
||||
admin_menu_icon: Some("CloudUpload"),
|
||||
admin_menu_group: None,
|
||||
admin_menu_order: 60,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "gemini_files",
|
||||
display_name: "文件缓存",
|
||||
@@ -183,6 +196,7 @@ pub(crate) struct AdminModuleRuntimeState {
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
s3_backup_configured: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_module_by_name(name: &str) -> Option<&'static AdminModuleDefinition> {
|
||||
@@ -209,6 +223,8 @@ pub(crate) fn admin_module_enabled_config_key(module: &AdminModuleDefinition) ->
|
||||
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
|
||||
} else if module.name == "important_notification" {
|
||||
IMPORTANT_NOTIFICATION_ENABLED_KEY.to_string()
|
||||
} else if module.name == "s3_backup" {
|
||||
crate::backup::S3_BACKUP_ENABLED_KEY.to_string()
|
||||
} else {
|
||||
format!("module.{}.enabled", module.name)
|
||||
}
|
||||
@@ -272,6 +288,7 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
let notification_configured = important_notification_configured(state.app()).await?;
|
||||
let server_chan_configured = server_chan_push_configured(state.app()).await?;
|
||||
let bark_configured = bark_push_configured(state.app()).await?;
|
||||
let backup_configured = s3_backup_configured(state.app()).await;
|
||||
|
||||
Ok(AdminModuleRuntimeState {
|
||||
oauth_providers,
|
||||
@@ -280,9 +297,21 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
important_notification_configured: notification_configured,
|
||||
server_chan_push_configured: server_chan_configured,
|
||||
bark_push_configured: bark_configured,
|
||||
s3_backup_configured: backup_configured,
|
||||
})
|
||||
}
|
||||
|
||||
async fn s3_backup_configured(app: &crate::AppState) -> bool {
|
||||
let Ok(mut values) = crate::backup::task::load_s3_backup_config_values(app).await else {
|
||||
return false;
|
||||
};
|
||||
values.insert(
|
||||
crate::backup::S3_BACKUP_ENABLED_KEY.to_string(),
|
||||
json!(true),
|
||||
);
|
||||
S3BackupConfig::from_json_map(&values).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_module_validation_result(
|
||||
module: &AdminModuleDefinition,
|
||||
runtime: &AdminModuleRuntimeState,
|
||||
@@ -295,6 +324,7 @@ pub(crate) fn build_admin_module_validation_result(
|
||||
runtime.important_notification_configured,
|
||||
runtime.server_chan_push_configured,
|
||||
runtime.bark_push_configured,
|
||||
runtime.s3_backup_configured,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -897,6 +897,15 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
||||
);
|
||||
assert_eq!(payload["bark_push"]["display_name"], "Bark 推送");
|
||||
assert_eq!(payload["bark_push"]["admin_route"], "/admin/modules/bark");
|
||||
assert_eq!(payload["s3_backup"]["display_name"], "S3 备份");
|
||||
assert_eq!(
|
||||
payload["s3_backup"]["admin_route"],
|
||||
"/admin/modules/s3-backup"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["s3_backup"]["admin_menu_group"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -1292,6 +1292,7 @@ pub fn build_admin_module_validation_result(
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
s3_backup_configured: bool,
|
||||
) -> (bool, Option<String>) {
|
||||
match module_name {
|
||||
"oauth" => {
|
||||
@@ -1383,6 +1384,13 @@ pub fn build_admin_module_validation_result(
|
||||
(false, Some("请先配置 Bark Device Key".to_string()))
|
||||
}
|
||||
}
|
||||
"s3_backup" => {
|
||||
if s3_backup_configured {
|
||||
(true, None)
|
||||
} else {
|
||||
(false, Some("请先完成 S3 备份配置".to_string()))
|
||||
}
|
||||
}
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
(true, None)
|
||||
@@ -1408,7 +1416,8 @@ pub fn build_admin_module_health(
|
||||
| "proxy_nodes"
|
||||
| "important_notification"
|
||||
| "bark_push"
|
||||
| "server_chan_push" => "healthy",
|
||||
| "server_chan_push"
|
||||
| "s3_backup" => "healthy",
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
"healthy"
|
||||
|
||||
@@ -960,6 +960,23 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
|
||||
{ key: 'module.bark_push.device_key', value: null, description: 'Bark Device Key' },
|
||||
{ key: 'module.bark_push.server_url', value: 'https://api.day.app', description: 'Bark 服务器地址' },
|
||||
{ key: 'module.bark_push.template', value: '', description: 'Bark 推送模板' },
|
||||
{ key: 'backup_s3_enabled', value: false, description: 'S3 自动备份开关' },
|
||||
{ key: 'backup_s3_scope', value: 'data', description: 'S3 备份范围' },
|
||||
{ key: 'backup_s3_endpoint', value: null, description: 'S3 Endpoint' },
|
||||
{ key: 'backup_s3_region', value: 'auto', description: 'S3 Region' },
|
||||
{ key: 'backup_s3_bucket', value: null, description: 'S3 Bucket' },
|
||||
{ key: 'backup_s3_prefix', value: 'aether/backups/', description: 'S3 备份前缀' },
|
||||
{ key: 'backup_s3_access_key_id', value: null, description: 'S3 Access Key ID' },
|
||||
{ key: 'backup_s3_secret_access_key', value: null, description: 'S3 Secret Access Key' },
|
||||
{ key: 'backup_s3_path_style', value: true, description: 'S3 Path Style' },
|
||||
{ key: 'backup_s3_compression', value: 'zstd', description: 'S3 备份压缩格式' },
|
||||
{ key: 'backup_s3_schedule_unit', value: 'days', description: 'S3 备份周期单位' },
|
||||
{ key: 'backup_s3_schedule_interval', value: 1, description: 'S3 备份周期间隔' },
|
||||
{ key: 'backup_s3_schedule_minute', value: 0, description: 'S3 备份分钟' },
|
||||
{ key: 'backup_s3_schedule_hour', value: 3, description: 'S3 备份小时' },
|
||||
{ key: 'backup_s3_schedule_weekday', value: 1, description: 'S3 备份星期' },
|
||||
{ key: 'backup_s3_schedule_month_day', value: 1, description: 'S3 备份月日' },
|
||||
{ key: 'backup_s3_retention_count', value: 7, description: 'S3 备份保留份数' },
|
||||
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
|
||||
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
|
||||
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
|
||||
@@ -1078,6 +1095,20 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
|
||||
admin_menu_group: null,
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
{
|
||||
name: 's3_backup',
|
||||
display_name: 'S3 备份',
|
||||
description: '将配置、用户或完整数据定期备份到 S3-compatible 对象存储',
|
||||
category: 'integration',
|
||||
available: true,
|
||||
enabled: false,
|
||||
config_validated: false,
|
||||
config_error: '请先完成 S3 备份配置',
|
||||
admin_route: '/admin/modules/s3-backup',
|
||||
admin_menu_icon: 'CloudUpload',
|
||||
admin_menu_group: null,
|
||||
admin_menu_order: 60,
|
||||
},
|
||||
{
|
||||
name: 'gemini_files',
|
||||
display_name: '文件缓存',
|
||||
|
||||
@@ -1796,6 +1796,42 @@ function generateMockModelsForProvider(providerId: string) {
|
||||
|
||||
// ========== 注册动态路由 ==========
|
||||
|
||||
const WRITE_ONLY_SYSTEM_CONFIG_KEYS = new Set([
|
||||
'module.server_chan_push.send_key',
|
||||
'module.bark_push.device_key',
|
||||
'backup_s3_secret_access_key',
|
||||
])
|
||||
|
||||
function mockSystemConfigValue(key: string) {
|
||||
return MOCK_SYSTEM_CONFIGS.find(item => item.key === key)?.value
|
||||
}
|
||||
|
||||
function mockS3BackupConfigValidated() {
|
||||
return [
|
||||
'backup_s3_endpoint',
|
||||
'backup_s3_bucket',
|
||||
'backup_s3_access_key_id',
|
||||
'backup_s3_secret_access_key',
|
||||
].every(key => {
|
||||
const value = mockSystemConfigValue(key)
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
})
|
||||
}
|
||||
|
||||
function refreshMockS3BackupModuleStatus() {
|
||||
const moduleStatus = MOCK_MODULE_STATUSES.s3_backup
|
||||
if (!moduleStatus) return
|
||||
const enabled = mockSystemConfigValue('backup_s3_enabled') === true
|
||||
const configValidated = mockS3BackupConfigValidated()
|
||||
MOCK_MODULE_STATUSES.s3_backup = {
|
||||
...moduleStatus,
|
||||
enabled,
|
||||
config_validated: configValidated,
|
||||
config_error: configValidated ? null : '请先完成 S3 备份配置',
|
||||
active: moduleStatus.available && enabled && configValidated,
|
||||
}
|
||||
}
|
||||
|
||||
// 系统配置详情
|
||||
registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => {
|
||||
await delay()
|
||||
@@ -1805,7 +1841,7 @@ registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_conf
|
||||
if (!entry) {
|
||||
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
|
||||
}
|
||||
if (key === 'module.server_chan_push.send_key' || key === 'module.bark_push.device_key') {
|
||||
if (WRITE_ONLY_SYSTEM_CONFIG_KEYS.has(key)) {
|
||||
return createMockResponse({
|
||||
key: entry.key,
|
||||
value: null,
|
||||
@@ -1836,9 +1872,26 @@ registerDynamicRoute('PUT', '/api/admin/system/configs/:configKey', async (confi
|
||||
...entry,
|
||||
}
|
||||
}
|
||||
if (key.startsWith('backup_s3_')) {
|
||||
refreshMockS3BackupModuleStatus()
|
||||
}
|
||||
return createMockResponse(entry)
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/system/backups/s3/run', async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
message: 'S3 备份任务已提交',
|
||||
task: {
|
||||
id: `mock-s3-backup-${Date.now()}`,
|
||||
task_key: 'system.s3.backup',
|
||||
status: 'queued',
|
||||
progress_message: 'S3 备份任务已提交',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 模块状态详情
|
||||
registerDynamicRoute('GET', '/api/admin/modules/status/:moduleName', async (_config, params) => {
|
||||
await delay()
|
||||
@@ -1860,6 +1913,21 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}') as { enabled?: boolean }
|
||||
const enabled = body.enabled === true
|
||||
if (params.moduleName === 's3_backup') {
|
||||
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === 'backup_s3_enabled')
|
||||
const entry = {
|
||||
key: 'backup_s3_enabled',
|
||||
value: enabled,
|
||||
description: 'S3 自动备份开关',
|
||||
}
|
||||
if (index === -1) {
|
||||
MOCK_SYSTEM_CONFIGS.push(entry)
|
||||
} else {
|
||||
MOCK_SYSTEM_CONFIGS[index] = { ...MOCK_SYSTEM_CONFIGS[index], ...entry }
|
||||
}
|
||||
refreshMockS3BackupModuleStatus()
|
||||
return createMockResponse(MOCK_MODULE_STATUSES.s3_backup)
|
||||
}
|
||||
const updated = {
|
||||
...moduleStatus,
|
||||
enabled,
|
||||
|
||||
@@ -274,6 +274,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
||||
meta: { module: 'chat_pii_redaction' }
|
||||
},
|
||||
{
|
||||
path: 'modules/s3-backup',
|
||||
name: 'S3BackupSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/S3BackupSettings.vue')),
|
||||
meta: { module: 's3_backup' }
|
||||
},
|
||||
{
|
||||
path: 'modules/important-notification',
|
||||
redirect: '/admin/notification-service'
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
@file-select="handleDataFileSelect"
|
||||
/>
|
||||
|
||||
<!-- S3 备份 -->
|
||||
<S3BackupSection id="section-s3-backup" />
|
||||
|
||||
<!-- 网络代理 -->
|
||||
<ProxyConfigSection
|
||||
id="section-proxy"
|
||||
@@ -270,7 +267,6 @@ import { useScheduledTasks } from './system-settings/composables/useScheduledTas
|
||||
// Section components
|
||||
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
|
||||
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
||||
import S3BackupSection from './system-settings/S3BackupSection.vue'
|
||||
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
||||
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
||||
import RequestLogSection from './system-settings/RequestLogSection.vue'
|
||||
@@ -289,7 +285,6 @@ const proxyNodesStore = useProxyNodesStore()
|
||||
const tocItems = [
|
||||
{ id: 'section-site-info', label: '站点信息' },
|
||||
{ id: 'section-data-mgmt', label: '数据管理' },
|
||||
{ id: 'section-s3-backup', label: 'S3 备份' },
|
||||
{ id: 'section-proxy', label: '网络代理' },
|
||||
{ id: 'section-basic', label: '基础配置' },
|
||||
{ id: 'section-request-log', label: '请求记录' },
|
||||
|
||||
+37
-29
@@ -1,31 +1,38 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="S3 备份"
|
||||
description="按导出范围备份到 S3-compatible 存储"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="backupRunDisabled"
|
||||
@click="backup.runS3BackupNow"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.running.value ? '提交中...' : '立即备份' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="backup.saving.value || !backup.hasChanges.value"
|
||||
@click="backup.saveS3BackupConfig"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.saving.value ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="S3 备份"
|
||||
description="按导出范围备份到 S3-compatible 存储"
|
||||
/>
|
||||
|
||||
<div class="space-y-5">
|
||||
<CardSection
|
||||
title="备份配置"
|
||||
description="配置自动备份周期、对象存储连接和保留策略"
|
||||
class="mt-6"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="backupRunDisabled"
|
||||
@click="backup.runS3BackupNow"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.running.value ? '提交中...' : '立即备份' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="backup.saving.value || !backup.hasChanges.value"
|
||||
@click="backup.saveS3BackupConfig"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.saving.value ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
@@ -395,8 +402,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</CardSection>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -418,7 +426,7 @@ import SelectItem from '@/components/ui/select-item.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
||||
import {
|
||||
useS3BackupConfig,
|
||||
type S3BackupConfig,
|
||||
Reference in New Issue
Block a user