feat: add webhook notification service and refactor data management
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

## Backend Changes
- Add WebhookService for sending alert notifications via HTTP webhooks
- Implement HMAC-SHA256 signature for webhook payload authentication
- Add webhook configuration API endpoints and settings
- Integrate webhook calls into OpsAlertEvaluatorService
- Fix routes/common.go string conversion (use strconv.Itoa)
- Add comprehensive webhook service tests

## Frontend Changes
- Add webhook notification configuration UI in OpsSettingsDialog
- Add WebhookNotificationConfig types and API functions
- Add i18n translations for webhook features (zh/en)
- Refactor DataManagementView.vue into modular components:
  - PostgresProfilesCard.vue (356 lines)
  - RedisProfilesCard.vue (331 lines)
  - S3ProfilesCard.vue (363 lines)
  - BackupJobsCard.vue (216 lines)
  - DataManagementView.vue (94 lines)
- Add OpsSettingsDialog component tests

## Testing
- All backend tests pass
- All frontend tests pass
- Webhook service tests cover signature, HTTP, timeout, error handling
This commit is contained in:
User
2026-04-15 23:03:48 +08:00
parent d96a9f384a
commit eb5d32553d
30 changed files with 3360 additions and 27 deletions

View File

@@ -94,7 +94,7 @@ export interface TestS3Request {
region: string
bucket: string
access_key_id: string
secret_access_key: string
secret_access_key?: string
prefix?: string
force_path_style?: boolean
use_ssl?: boolean

View File

@@ -804,6 +804,25 @@ export interface EmailNotificationConfig {
}
}
export interface WebhookNotificationConfig {
alert: {
enabled: boolean
urls: string[]
secret?: string
min_severity: AlertSeverity | ''
timeout_seconds: number
include_resolved: boolean
rate_limit_per_hour: number
}
report: {
enabled: boolean
urls: string[]
secret?: string
daily_enabled: boolean
daily_schedule: string
}
}
export interface OpsMetricThresholds {
sla_percent_min?: number | null // SLA低于此值变红
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
@@ -1300,6 +1319,17 @@ export async function updateEmailNotificationConfig(config: EmailNotificationCon
return data
}
// Webhook notification config (DB-backed)
export async function getWebhookNotificationConfig(): Promise<WebhookNotificationConfig> {
const { data } = await apiClient.get<WebhookNotificationConfig>('/admin/ops/webhook-notification/config')
return data
}
export async function updateWebhookNotificationConfig(config: WebhookNotificationConfig): Promise<WebhookNotificationConfig> {
const { data } = await apiClient.put<WebhookNotificationConfig>('/admin/ops/webhook-notification/config', config)
return data
}
// Runtime settings (DB-backed)
export async function getAlertRuntimeSettings(): Promise<OpsAlertRuntimeSettings> {
const { data } = await apiClient.get<OpsAlertRuntimeSettings>('/admin/ops/runtime/alert')
@@ -1407,6 +1437,8 @@ export const opsAPI = {
createAlertSilence,
getEmailNotificationConfig,
updateEmailNotificationConfig,
getWebhookNotificationConfig,
updateWebhookNotificationConfig,
getAlertRuntimeSettings,
updateAlertRuntimeSettings,
getRuntimeLogConfig,

View File

@@ -4048,6 +4048,34 @@ export default {
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
}
},
webhookNotification: {
title: 'Webhook Notification Config',
description: 'Configure alert webhook notifications for enterprise IM integration (DingTalk, Feishu, WeChat Work, etc.).',
loading: 'Loading...',
loadFailed: 'Failed to load webhook config',
saveSuccess: 'Webhook config saved',
saveFailed: 'Failed to save webhook config',
alertTitle: 'Alert Webhooks',
reportTitle: 'Report Webhooks',
urls: 'Webhook URLs',
urlsHint: 'One URL per line, multiple webhooks supported',
secret: 'Signing Secret',
secretHint: 'Optional, used to generate HMAC-SHA256 signature',
minSeverity: 'Minimum Severity',
minSeverityAll: 'All severities',
timeoutSeconds: 'Timeout (seconds)',
includeResolved: 'Include resolved alerts',
rateLimitPerHour: 'Rate limit per hour',
dailyReport: 'Daily Report',
validation: {
title: 'Please fix the following issues',
invalid: 'Invalid webhook config',
urlsRequired: 'Webhook enabled but no URLs configured',
invalidUrls: 'Invalid URL format',
timeoutRange: 'Timeout must be between 1 and 60 seconds',
rateLimitRange: 'Rate limit must be >= 0'
}
},
settings: {
title: 'Ops Monitoring Settings',
loadFailed: 'Failed to load settings',

View File

@@ -4212,6 +4212,34 @@ export default {
accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间'
}
},
webhookNotification: {
title: 'Webhook 通知配置',
description: '配置告警 Webhook 通知,支持集成企业 IM钉钉、飞书、企业微信等。',
loading: '加载中...',
loadFailed: '加载 Webhook 配置失败',
saveSuccess: 'Webhook 配置已保存',
saveFailed: '保存 Webhook 配置失败',
alertTitle: '告警 Webhook',
reportTitle: '报告 Webhook',
urls: 'Webhook URL',
urlsHint: '每行一个 URL支持多个 Webhook',
secret: '签名密钥',
secretHint: '可选,用于生成 HMAC-SHA256 签名',
minSeverity: '最低级别',
minSeverityAll: '全部级别',
timeoutSeconds: '超时时间(秒)',
includeResolved: '包含恢复通知',
rateLimitPerHour: '每小时限额',
dailyReport: '每日报告',
validation: {
title: '请先修正以下问题',
invalid: 'Webhook 配置不合法',
urlsRequired: '已启用 Webhook但未配置任何 URL',
invalidUrls: '存在不合法的 URL',
timeoutRange: '超时时间必须在 1 到 60 秒之间',
rateLimitRange: '每小时限额必须为 ≥ 0 的数字'
}
},
settings: {
title: '运维监控设置',
loadFailed: '加载设置失败',

View File

@@ -455,6 +455,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.usage.description'
}
},
{
path: '/admin/data-management',
name: 'AdminDataManagement',
component: () => import('@/views/admin/data-management/DataManagementView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Data Management',
titleKey: 'admin.dataManagement.title',
descriptionKey: 'admin.dataManagement.description'
}
},
// ==================== Payment Admin Routes ====================

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
dataManagementAPI,
type BackupAgentHealth
} from '@/api/admin/dataManagement'
import PostgresProfilesCard from './components/PostgresProfilesCard.vue'
import RedisProfilesCard from './components/RedisProfilesCard.vue'
import S3ProfilesCard from './components/S3ProfilesCard.vue'
import BackupJobsCard from './components/BackupJobsCard.vue'
const { t } = useI18n()
const agentHealth = ref<BackupAgentHealth | null>(null)
const postgresCard = ref<InstanceType<typeof PostgresProfilesCard> | null>(null)
const redisCard = ref<InstanceType<typeof RedisProfilesCard> | null>(null)
const s3Card = ref<InstanceType<typeof S3ProfilesCard> | null>(null)
const backupCard = ref<InstanceType<typeof BackupJobsCard> | null>(null)
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
async function fetchAgentHealth() {
try {
agentHealth.value = await dataManagementAPI.getAgentHealth()
} catch (err: any) {
console.error('[DataManagementView] Failed to fetch agent health', err)
}
}
onMounted(fetchAgentHealth)
</script>
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.description') }}
</p>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="agentHealth?.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
>
{{ agentHealth?.enabled ? t('admin.dataManagement.agent.enabled') : t('admin.dataManagement.agent.disabled') }}
</span>
</div>
</div>
<!-- Agent Status Card -->
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.agent.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ agentHealth?.reason || t('admin.dataManagement.agent.statusUnknown') }}
</p>
</div>
<div v-if="agentHealth?.agent" class="text-right text-sm text-gray-500 dark:text-gray-400">
<div>{{ t('admin.dataManagement.agent.version') }}: {{ agentHealth.agent.version }}</div>
<div>{{ t('admin.dataManagement.agent.uptime') }}: {{ formatUptime(agentHealth.agent.uptime_seconds) }}</div>
</div>
</div>
</div>
<!-- PostgreSQL Profiles -->
<PostgresProfilesCard ref="postgresCard" />
<!-- Redis Profiles -->
<RedisProfilesCard ref="redisCard" />
<!-- S3 Profiles -->
<S3ProfilesCard ref="s3Card" />
<!-- Backup Jobs -->
<BackupJobsCard ref="backupCard" />
</div>
</template>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import {
dataManagementAPI,
type BackupJob,
type BackupType
} from '@/api/admin/dataManagement'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const jobs = ref<BackupJob[]>([])
const showModal = ref(false)
const saving = ref(false)
const form = ref<{
backup_type: BackupType
postgres_profile_id: string
redis_profile_id: string
s3_profile_id: string
}>({
backup_type: 'full',
postgres_profile_id: '',
redis_profile_id: '',
s3_profile_id: ''
})
function formatTime(time: string): string {
if (!time) return '-'
return new Date(time).toLocaleString()
}
function getJobStatusClass(status: string): string {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
case 'running':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
case 'failed':
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
case 'pending':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'
}
}
async function fetchJobs() {
loading.value = true
try {
const resp = await dataManagementAPI.listBackupJobs()
jobs.value = resp.items
} catch (err: any) {
console.error('[BackupJobsCard] Failed to fetch jobs', err)
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
} finally {
loading.value = false
}
}
function openModal() {
form.value = {
backup_type: 'full',
postgres_profile_id: '',
redis_profile_id: '',
s3_profile_id: ''
}
showModal.value = true
}
function closeModal() {
showModal.value = false
}
async function createJob() {
saving.value = true
try {
await dataManagementAPI.createBackupJob(form.value)
await fetchJobs()
closeModal()
appStore.showSuccess(t('admin.dataManagement.backupJobs.created'))
} catch (err: any) {
console.error('[BackupJobsCard] Failed to create job', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(fetchJobs)
defineExpose({ refresh: fetchJobs })
</script>
<template>
<div class="card p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.backupJobs.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.description') }}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm" @click="openModal">
{{ t('admin.dataManagement.backupJobs.create') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="jobs.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.noJobs') }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.jobId') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.type') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.status') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.startedAt') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.finishedAt') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="job in jobs" :key="job.job_id">
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-900 dark:text-white">
{{ job.job_id.slice(0, 8) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ job.backup_type }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="getJobStatusClass(job.status)"
>
{{ job.status }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ job.started_at ? formatTime(job.started_at) : '-' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ job.finished_at ? formatTime(job.finished_at) : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Backup Job Modal -->
<BaseDialog
:show="showModal"
:title="t('admin.dataManagement.backupJobs.create')"
@close="closeModal"
>
<form @submit.prevent="createJob">
<div class="space-y-4">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.type') }}
</label>
<select v-model="form.backup_type" class="input w-full">
<option value="full">Full</option>
<option value="incremental">Incremental</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.postgresProfile') }}
</label>
<input v-model="form.postgres_profile_id" class="input w-full" placeholder="Profile ID" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.redisProfile') }}
</label>
<input v-model="form.redis_profile_id" class="input w-full" placeholder="Profile ID" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.backupJobs.s3Profile') }}
</label>
<input v-model="form.s3_profile_id" class="input w-full" placeholder="Profile ID" />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="closeModal">
{{ t('common.cancel') }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? t('common.loading') : t('common.create') }}
</button>
</div>
</form>
</BaseDialog>
</div>
</template>

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import {
dataManagementAPI,
type DataManagementSourceProfile
} from '@/api/admin/dataManagement'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const profiles = ref<DataManagementSourceProfile[]>([])
const showModal = ref(false)
const editing = ref<DataManagementSourceProfile | null>(null)
const saving = ref(false)
const form = ref<{
profile_id: string
name: string
config: {
host: string
port: number
user: string
password: string
database: string
ssl_mode: string
container_name: string
}
set_active: boolean
}>({
profile_id: '',
name: '',
config: {
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
database: '',
ssl_mode: 'disable',
container_name: ''
},
set_active: false
})
async function fetchProfiles() {
loading.value = true
try {
const resp = await dataManagementAPI.listSourceProfiles('postgres')
profiles.value = resp.items
} catch (err: any) {
console.error('[PostgresProfilesCard] Failed to fetch profiles', err)
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
} finally {
loading.value = false
}
}
function openModal(profile?: DataManagementSourceProfile) {
if (profile) {
editing.value = profile
form.value = {
profile_id: profile.profile_id,
name: profile.name,
config: {
host: profile.config.host,
port: profile.config.port,
user: profile.config.user,
password: '',
database: profile.config.database,
ssl_mode: profile.config.ssl_mode,
container_name: profile.config.container_name
},
set_active: false
}
} else {
editing.value = null
form.value = {
profile_id: '',
name: '',
config: {
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
database: '',
ssl_mode: 'disable',
container_name: ''
},
set_active: false
}
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
saving.value = true
try {
if (editing.value) {
await dataManagementAPI.updateSourceProfile('postgres', form.value.profile_id, {
name: form.value.name,
config: {
host: form.value.config.host,
port: form.value.config.port,
user: form.value.config.user,
password: form.value.config.password,
database: form.value.config.database,
ssl_mode: form.value.config.ssl_mode,
container_name: form.value.config.container_name,
addr: '',
username: '',
db: 0
}
})
} else {
await dataManagementAPI.createSourceProfile('postgres', {
profile_id: form.value.profile_id,
name: form.value.name,
config: {
host: form.value.config.host,
port: form.value.config.port,
user: form.value.config.user,
password: form.value.config.password,
database: form.value.config.database,
ssl_mode: form.value.config.ssl_mode,
container_name: form.value.config.container_name,
addr: '',
username: '',
db: 0
},
set_active: form.value.set_active
})
}
await fetchProfiles()
closeModal()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[PostgresProfilesCard] Failed to save profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function activate(profileId: string) {
try {
await dataManagementAPI.setActiveSourceProfile('postgres', profileId)
await fetchProfiles()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[PostgresProfilesCard] Failed to activate profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
}
}
async function remove(profileId: string) {
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
try {
await dataManagementAPI.deleteSourceProfile('postgres', profileId)
await fetchProfiles()
appStore.showSuccess(t('common.deleted'))
} catch (err: any) {
console.error('[PostgresProfilesCard] Failed to delete profile', err)
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
}
}
onMounted(fetchProfiles)
defineExpose({ refresh: fetchProfiles })
</script>
<template>
<div class="card p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.postgres.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.description') }}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
{{ t('admin.dataManagement.postgres.addProfile') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.noProfiles') }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.host') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.database') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.status') }}
</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="profile in profiles" :key="profile.profile_id">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{{ profile.name }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.config.host }}:{{ profile.config.port }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.config.database }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
>
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
v-if="!profile.is_active"
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="activate(profile.profile_id)"
>
{{ t('admin.dataManagement.profiles.activate') }}
</button>
<button
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="openModal(profile)"
>
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:text-red-700"
@click="remove(profile.profile_id)"
>
{{ t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal -->
<BaseDialog
:show="showModal"
:title="editing ? t('admin.dataManagement.postgres.editProfile') : t('admin.dataManagement.postgres.addProfile')"
width="wide"
@close="closeModal"
>
<form @submit.prevent="save">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.profileId') }}
</label>
<input
v-model="form.profile_id"
:disabled="!!editing"
class="input w-full"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</label>
<input v-model="form.name" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.host') }}
</label>
<input v-model="form.config.host" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.port') }}
</label>
<input v-model.number="form.config.port" type="number" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.user') }}
</label>
<input v-model="form.config.user" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.password') }}
</label>
<input v-model="form.config.password" type="password" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.database') }}
</label>
<input v-model="form.config.database" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.sslMode') }}
</label>
<select v-model="form.config.ssl_mode" class="input w-full">
<option value="disable">disable</option>
<option value="require">require</option>
<option value="verify-ca">verify-ca</option>
<option value="verify-full">verify-full</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.postgres.containerName') }}
</label>
<input v-model="form.config.container_name" class="input w-full" />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="closeModal">
{{ t('common.cancel') }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? t('common.loading') : t('common.save') }}
</button>
</div>
</form>
</BaseDialog>
</div>
</template>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import {
dataManagementAPI,
type DataManagementSourceProfile
} from '@/api/admin/dataManagement'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const profiles = ref<DataManagementSourceProfile[]>([])
const showModal = ref(false)
const editing = ref<DataManagementSourceProfile | null>(null)
const saving = ref(false)
const form = ref<{
profile_id: string
name: string
config: {
addr: string
username: string
password: string
db: number
container_name: string
}
set_active: boolean
}>({
profile_id: '',
name: '',
config: {
addr: 'localhost:6379',
username: '',
password: '',
db: 0,
container_name: ''
},
set_active: false
})
async function fetchProfiles() {
loading.value = true
try {
const resp = await dataManagementAPI.listSourceProfiles('redis')
profiles.value = resp.items
} catch (err: any) {
console.error('[RedisProfilesCard] Failed to fetch profiles', err)
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
} finally {
loading.value = false
}
}
function openModal(profile?: DataManagementSourceProfile) {
if (profile) {
editing.value = profile
form.value = {
profile_id: profile.profile_id,
name: profile.name,
config: {
addr: profile.config.addr,
username: profile.config.username,
password: '',
db: profile.config.db,
container_name: profile.config.container_name
},
set_active: false
}
} else {
editing.value = null
form.value = {
profile_id: '',
name: '',
config: {
addr: 'localhost:6379',
username: '',
password: '',
db: 0,
container_name: ''
},
set_active: false
}
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
saving.value = true
try {
if (editing.value) {
await dataManagementAPI.updateSourceProfile('redis', form.value.profile_id, {
name: form.value.name,
config: {
host: '',
port: 0,
user: '',
password: form.value.config.password,
database: '',
ssl_mode: '',
addr: form.value.config.addr,
username: form.value.config.username,
db: form.value.config.db,
container_name: form.value.config.container_name
}
})
} else {
await dataManagementAPI.createSourceProfile('redis', {
profile_id: form.value.profile_id,
name: form.value.name,
config: {
host: '',
port: 0,
user: '',
password: form.value.config.password,
database: '',
ssl_mode: '',
addr: form.value.config.addr,
username: form.value.config.username,
db: form.value.config.db,
container_name: form.value.config.container_name
},
set_active: form.value.set_active
})
}
await fetchProfiles()
closeModal()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[RedisProfilesCard] Failed to save profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function activate(profileId: string) {
try {
await dataManagementAPI.setActiveSourceProfile('redis', profileId)
await fetchProfiles()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[RedisProfilesCard] Failed to activate profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
}
}
async function remove(profileId: string) {
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
try {
await dataManagementAPI.deleteSourceProfile('redis', profileId)
await fetchProfiles()
appStore.showSuccess(t('common.deleted'))
} catch (err: any) {
console.error('[RedisProfilesCard] Failed to delete profile', err)
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
}
}
onMounted(fetchProfiles)
defineExpose({ refresh: fetchProfiles })
</script>
<template>
<div class="card p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.redis.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.redis.description') }}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
{{ t('admin.dataManagement.redis.addProfile') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.redis.noProfiles') }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.redis.address') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.redis.database') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.status') }}
</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="profile in profiles" :key="profile.profile_id">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{{ profile.name }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.config.addr }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.config.db }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
>
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
v-if="!profile.is_active"
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="activate(profile.profile_id)"
>
{{ t('admin.dataManagement.profiles.activate') }}
</button>
<button
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="openModal(profile)"
>
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:text-red-700"
@click="remove(profile.profile_id)"
>
{{ t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal -->
<BaseDialog
:show="showModal"
:title="editing ? t('admin.dataManagement.redis.editProfile') : t('admin.dataManagement.redis.addProfile')"
width="wide"
@close="closeModal"
>
<form @submit.prevent="save">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.profileId') }}
</label>
<input
v-model="form.profile_id"
:disabled="!!editing"
class="input w-full"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</label>
<input v-model="form.name" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.redis.address') }}
</label>
<input v-model="form.config.addr" class="input w-full" placeholder="localhost:6379" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.redis.username') }}
</label>
<input v-model="form.config.username" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.redis.password') }}
</label>
<input v-model="form.config.password" type="password" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.redis.database') }}
</label>
<input v-model.number="form.config.db" type="number" min="0" class="input w-full" />
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.redis.containerName') }}
</label>
<input v-model="form.config.container_name" class="input w-full" />
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="closeModal">
{{ t('common.cancel') }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? t('common.loading') : t('common.save') }}
</button>
</div>
</form>
</BaseDialog>
</div>
</template>

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import {
dataManagementAPI,
type DataManagementS3Profile,
type TestS3Request
} from '@/api/admin/dataManagement'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const profiles = ref<DataManagementS3Profile[]>([])
const showModal = ref(false)
const editing = ref<DataManagementS3Profile | null>(null)
const saving = ref(false)
const form = ref<{
profile_id: string
name: string
enabled: boolean
endpoint: string
region: string
bucket: string
access_key_id: string
secret_access_key: string
prefix: string
force_path_style: boolean
use_ssl: boolean
set_active: boolean
}>({
profile_id: '',
name: '',
enabled: true,
endpoint: '',
region: 'us-east-1',
bucket: '',
access_key_id: '',
secret_access_key: '',
prefix: '',
force_path_style: false,
use_ssl: true,
set_active: false
})
async function fetchProfiles() {
loading.value = true
try {
const resp = await dataManagementAPI.listS3Profiles()
profiles.value = resp.items
} catch (err: any) {
console.error('[S3ProfilesCard] Failed to fetch profiles', err)
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
} finally {
loading.value = false
}
}
function openModal(profile?: DataManagementS3Profile) {
if (profile) {
editing.value = profile
form.value = {
profile_id: profile.profile_id,
name: profile.name,
enabled: profile.s3.enabled,
endpoint: profile.s3.endpoint,
region: profile.s3.region,
bucket: profile.s3.bucket,
access_key_id: profile.s3.access_key_id,
secret_access_key: '',
prefix: profile.s3.prefix,
force_path_style: profile.s3.force_path_style,
use_ssl: profile.s3.use_ssl,
set_active: false
}
} else {
editing.value = null
form.value = {
profile_id: '',
name: '',
enabled: true,
endpoint: '',
region: 'us-east-1',
bucket: '',
access_key_id: '',
secret_access_key: '',
prefix: '',
force_path_style: false,
use_ssl: true,
set_active: false
}
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
saving.value = true
try {
if (editing.value) {
await dataManagementAPI.updateS3Profile(form.value.profile_id, {
name: form.value.name,
enabled: form.value.enabled,
endpoint: form.value.endpoint,
region: form.value.region,
bucket: form.value.bucket,
access_key_id: form.value.access_key_id,
secret_access_key: form.value.secret_access_key || undefined,
prefix: form.value.prefix,
force_path_style: form.value.force_path_style,
use_ssl: form.value.use_ssl
})
} else {
await dataManagementAPI.createS3Profile(form.value)
}
await fetchProfiles()
closeModal()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[S3ProfilesCard] Failed to save profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function test(profile: DataManagementS3Profile) {
try {
const req: TestS3Request = {
endpoint: profile.s3.endpoint,
region: profile.s3.region,
bucket: profile.s3.bucket,
access_key_id: profile.s3.access_key_id,
prefix: profile.s3.prefix,
force_path_style: profile.s3.force_path_style,
use_ssl: profile.s3.use_ssl
}
await dataManagementAPI.testS3(req)
appStore.showSuccess(t('admin.dataManagement.s3.testSuccess'))
} catch (err: any) {
console.error('[S3ProfilesCard] Failed to test profile', err)
appStore.showError(err?.response?.data?.detail || t('admin.dataManagement.s3.testFailed'))
}
}
async function activate(profileId: string) {
try {
await dataManagementAPI.setActiveS3Profile(profileId)
await fetchProfiles()
appStore.showSuccess(t('common.saved'))
} catch (err: any) {
console.error('[S3ProfilesCard] Failed to activate profile', err)
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
}
}
async function remove(profileId: string) {
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
try {
await dataManagementAPI.deleteS3Profile(profileId)
await fetchProfiles()
appStore.showSuccess(t('common.deleted'))
} catch (err: any) {
console.error('[S3ProfilesCard] Failed to delete profile', err)
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
}
}
onMounted(fetchProfiles)
defineExpose({ refresh: fetchProfiles })
</script>
<template>
<div class="card p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.dataManagement.s3.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.s3.description') }}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
{{ t('admin.dataManagement.s3.addProfile') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.s3.noProfiles') }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.s3.bucket') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.s3.region') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.status') }}
</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="profile in profiles" :key="profile.profile_id">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{{ profile.name }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.s3.bucket }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ profile.s3.region }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
>
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="test(profile)"
>
{{ t('admin.dataManagement.s3.test') }}
</button>
<button
v-if="!profile.is_active"
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="activate(profile.profile_id)"
>
{{ t('admin.dataManagement.profiles.activate') }}
</button>
<button
type="button"
class="mr-2 text-primary-600 hover:text-primary-700"
@click="openModal(profile)"
>
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:text-red-700"
@click="remove(profile.profile_id)"
>
{{ t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal -->
<BaseDialog
:show="showModal"
:title="editing ? t('admin.dataManagement.s3.editProfile') : t('admin.dataManagement.s3.addProfile')"
width="wide"
@close="closeModal"
>
<form @submit.prevent="save">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.profileId') }}
</label>
<input
v-model="form.profile_id"
:disabled="!!editing"
class="input w-full"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.profiles.name') }}
</label>
<input v-model="form.name" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.endpoint') }}
</label>
<input v-model="form.endpoint" class="input w-full" placeholder="https://s3.amazonaws.com" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.region') }}
</label>
<input v-model="form.region" class="input w-full" placeholder="us-east-1" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.bucket') }}
</label>
<input v-model="form.bucket" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.prefix') }}
</label>
<input v-model="form.prefix" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.accessKeyId') }}
</label>
<input v-model="form.access_key_id" class="input w-full" required />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.dataManagement.s3.secretAccessKey') }}
</label>
<input v-model="form.secret_access_key" type="password" class="input w-full" :placeholder="editing ? t('admin.dataManagement.s3.secretPlaceholder') : ''" />
</div>
<div class="flex items-center gap-4 md:col-span-2">
<label class="inline-flex items-center gap-2">
<input v-model="form.use_ssl" type="checkbox" class="checkbox" />
<span class="text-sm">{{ t('admin.dataManagement.s3.useSsl') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input v-model="form.force_path_style" type="checkbox" class="checkbox" />
<span class="text-sm">{{ t('admin.dataManagement.s3.forcePathStyle') }}</span>
</label>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="closeModal">
{{ t('common.cancel') }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? t('common.loading') : t('common.save') }}
</button>
</div>
</form>
</BaseDialog>
</div>
</template>

View File

@@ -6,7 +6,7 @@ import { opsAPI } from '@/api/admin/ops'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, WebhookNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
const { t } = useI18n()
const appStore = useAppStore()
@@ -27,6 +27,8 @@ const saving = ref(false)
const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
// 邮件通知配置
const emailConfig = ref<EmailNotificationConfig | null>(null)
// Webhook通知配置
const webhookConfig = ref<WebhookNotificationConfig | null>(null)
// 高级设置
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 指标阈值配置
@@ -41,14 +43,16 @@ const metricThresholds = ref<OpsMetricThresholds>({
async function loadAllSettings() {
loading.value = true
try {
const [runtime, email, advanced, thresholds] = await Promise.all([
const [runtime, email, webhook, advanced, thresholds] = await Promise.all([
opsAPI.getAlertRuntimeSettings(),
opsAPI.getEmailNotificationConfig(),
opsAPI.getWebhookNotificationConfig(),
opsAPI.getAdvancedSettings(),
opsAPI.getMetricThresholds()
])
runtimeSettings.value = runtime
emailConfig.value = email
webhookConfig.value = webhook
advancedSettings.value = advanced
// 如果后端返回了阈值,使用后端的值;否则保持默认值
if (thresholds && Object.keys(thresholds).length > 0) {
@@ -78,6 +82,10 @@ watch(() => props.show, (show) => {
const alertRecipientInput = ref('')
const reportRecipientInput = ref('')
// Webhook URL输入
const alertUrlInput = ref('')
const reportUrlInput = ref('')
// 严重级别选项
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
{ value: '', label: t('admin.ops.email.minSeverityAll') },
@@ -91,6 +99,16 @@ function isValidEmailAddress(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// 验证URL
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
// 添加收件人
function addRecipient(target: 'alert' | 'report') {
if (!emailConfig.value) return
@@ -119,6 +137,33 @@ function removeRecipient(target: 'alert' | 'report', email: string) {
if (idx >= 0) list.splice(idx, 1)
}
// 添加Webhook URL
function addWebhookUrl(target: 'alert' | 'report') {
if (!webhookConfig.value) return
const raw = (target === 'alert' ? alertUrlInput.value : reportUrlInput.value).trim()
if (!raw) return
if (!isValidUrl(raw)) {
appStore.showError(t('admin.ops.webhookNotification.validation.invalidUrl'))
return
}
const list = target === 'alert' ? webhookConfig.value.alert.urls : webhookConfig.value.report.urls
if (!list.includes(raw)) {
list.push(raw)
}
if (target === 'alert') alertUrlInput.value = ''
else reportUrlInput.value = ''
}
// 移除Webhook URL
function removeWebhookUrl(target: 'alert' | 'report', url: string) {
if (!webhookConfig.value) return
const list = target === 'alert' ? webhookConfig.value.alert.urls : webhookConfig.value.report.urls
const idx = list.indexOf(url)
if (idx >= 0) list.splice(idx, 1)
}
// 验证
const validation = computed(() => {
const errors: string[] = []
@@ -185,6 +230,7 @@ async function saveAllSettings() {
await Promise.all([
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
webhookConfig.value ? opsAPI.updateWebhookNotificationConfig(webhookConfig.value) : Promise.resolve(),
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve(),
opsAPI.updateMetricThresholds(metricThresholds.value)
])
@@ -206,7 +252,7 @@ async function saveAllSettings() {
{{ t('common.loading') }}
</div>
<div v-else-if="runtimeSettings && emailConfig && advancedSettings" class="space-y-6">
<div v-else-if="runtimeSettings && emailConfig && webhookConfig && advancedSettings" class="space-y-6">
<!-- 验证错误 -->
<div v-if="!validation.valid" class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
<div class="font-bold">{{ t('admin.ops.settings.validation.title') }}</div>
@@ -339,6 +385,126 @@ async function saveAllSettings() {
</div>
</div>
<!-- Webhook通知配置 -->
<div v-if="webhookConfig" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.webhookNotification.title') }}</h4>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.webhookNotification.description') }}</p>
<!-- Alert Webhook -->
<div class="mb-6 space-y-4">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.alertTitle') }}</h5>
<div class="flex items-center justify-between">
<label class="font-medium text-gray-900 dark:text-white">{{ t('common.enable') }}</label>
<Toggle v-model="webhookConfig.alert.enabled" />
</div>
<div v-if="webhookConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.webhookNotification.urls') }}</label>
<div class="flex gap-2">
<input
v-model="alertUrlInput"
type="url"
class="input flex-1"
:placeholder="'https://example.com/webhook'"
@keydown.enter.prevent="addWebhookUrl('alert')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addWebhookUrl('alert')">
{{ t('common.add') }}
</button>
</div>
<div v-if="webhookConfig.alert.urls.length > 0" class="mt-2 flex flex-wrap gap-2">
<span
v-for="url in webhookConfig.alert.urls"
:key="url"
class="inline-flex items-center gap-2 rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
<span class="max-w-[200px] truncate">{{ url }}</span>
<button type="button" class="text-purple-700/80 hover:text-purple-900" @click="removeWebhookUrl('alert', url)">×</button>
</span>
</div>
</div>
<div v-if="webhookConfig.alert.enabled" class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.ops.webhookNotification.minSeverity') }}</label>
<Select v-model="webhookConfig.alert.min_severity" :options="severityOptions" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.webhookNotification.timeoutSeconds') }}</label>
<input v-model.number="webhookConfig.alert.timeout_seconds" type="number" min="1" max="60" class="input" />
</div>
</div>
<div v-if="webhookConfig.alert.enabled" class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.ops.webhookNotification.rateLimitPerHour') }}</label>
<input v-model.number="webhookConfig.alert.rate_limit_per_hour" type="number" min="0" class="input" />
</div>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.includeResolved') }}</label>
<Toggle v-model="webhookConfig.alert.include_resolved" />
</div>
</div>
<div v-if="webhookConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.webhookNotification.secret') }}</label>
<input
v-model="webhookConfig.alert.secret"
type="password"
class="input"
:placeholder="t('admin.ops.webhookNotification.secretHint')"
/>
</div>
</div>
<!-- Report Webhook -->
<div class="space-y-4">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.reportTitle') }}</h5>
<div class="flex items-center justify-between">
<label class="font-medium text-gray-900 dark:text-white">{{ t('common.enable') }}</label>
<Toggle v-model="webhookConfig.report.enabled" />
</div>
<div v-if="webhookConfig.report.enabled">
<label class="input-label">{{ t('admin.ops.webhookNotification.urls') }}</label>
<div class="flex gap-2">
<input
v-model="reportUrlInput"
type="url"
class="input flex-1"
:placeholder="'https://example.com/webhook'"
@keydown.enter.prevent="addWebhookUrl('report')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addWebhookUrl('report')">
{{ t('common.add') }}
</button>
</div>
<div v-if="webhookConfig.report.urls.length > 0" class="mt-2 flex flex-wrap gap-2">
<span
v-for="url in webhookConfig.report.urls"
:key="url"
class="inline-flex items-center gap-2 rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
<span class="max-w-[200px] truncate">{{ url }}</span>
<button type="button" class="text-purple-700/80 hover:text-purple-900" @click="removeWebhookUrl('report', url)">×</button>
</span>
</div>
</div>
<div v-if="webhookConfig.report.enabled">
<label class="input-label">{{ t('admin.ops.webhookNotification.secret') }}</label>
<input
v-model="webhookConfig.report.secret"
type="password"
class="input"
:placeholder="t('admin.ops.webhookNotification.secretHint')"
/>
</div>
</div>
</div>
<!-- 指标阈值配置 -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.metricThresholds') }}</h4>

View File

@@ -0,0 +1,373 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { opsAPI } from '@/api/admin/ops'
import type { WebhookNotificationConfig, AlertSeverity } from '@/api/admin/ops'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const config = ref<WebhookNotificationConfig | null>(null)
const showEditor = ref(false)
const saving = ref(false)
const draft = ref<WebhookNotificationConfig | null>(null)
const alertUrlInput = ref('')
const reportUrlInput = ref('')
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
{ value: '', label: t('admin.ops.webhookNotification.minSeverityAll') },
{ value: 'critical', label: t('common.critical') },
{ value: 'warning', label: t('common.warning') },
{ value: 'info', label: t('common.info') }
]
async function loadConfig() {
loading.value = true
try {
const data = await opsAPI.getWebhookNotificationConfig()
config.value = data
} catch (err: any) {
console.error('[OpsWebhookNotificationCard] Failed to load config', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.webhookNotification.loadFailed'))
} finally {
loading.value = false
}
}
async function saveConfig() {
if (!draft.value) return
if (!editorValidation.value.valid) {
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.webhookNotification.validation.invalid'))
return
}
saving.value = true
try {
config.value = await opsAPI.updateWebhookNotificationConfig(draft.value)
showEditor.value = false
appStore.showSuccess(t('admin.ops.webhookNotification.saveSuccess'))
} catch (err: any) {
console.error('[OpsWebhookNotificationCard] Failed to save config', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.webhookNotification.saveFailed'))
} finally {
saving.value = false
}
}
function openEditor() {
if (!config.value) return
draft.value = JSON.parse(JSON.stringify(config.value))
alertUrlInput.value = ''
reportUrlInput.value = ''
showEditor.value = true
}
function isValidUrl(url: string): boolean {
try {
new URL(url)
return url.startsWith('http://') || url.startsWith('https://')
} catch {
return false
}
}
function addAlertUrl() {
if (!draft.value) return
const url = alertUrlInput.value.trim()
if (url && isValidUrl(url)) {
if (!draft.value.alert.urls.includes(url)) {
draft.value.alert.urls.push(url)
}
alertUrlInput.value = ''
}
}
function removeAlertUrl(index: number) {
if (!draft.value) return
draft.value.alert.urls.splice(index, 1)
}
function addReportUrl() {
if (!draft.value) return
const url = reportUrlInput.value.trim()
if (url && isValidUrl(url)) {
if (!draft.value.report.urls.includes(url)) {
draft.value.report.urls.push(url)
}
reportUrlInput.value = ''
}
}
function removeReportUrl(index: number) {
if (!draft.value) return
draft.value.report.urls.splice(index, 1)
}
const editorValidation = computed(() => {
const errors: string[] = []
if (!draft.value) return { valid: true, errors }
if (draft.value.alert.enabled && draft.value.alert.urls.length === 0) {
errors.push(t('admin.ops.webhookNotification.validation.urlsRequired'))
}
if (draft.value.report.enabled && draft.value.report.urls.length === 0) {
errors.push(t('admin.ops.webhookNotification.validation.urlsRequired'))
}
const invalidAlertUrls = draft.value.alert.urls.filter((u) => !isValidUrl(u))
if (invalidAlertUrls.length > 0) errors.push(t('admin.ops.webhookNotification.validation.invalidUrls'))
const invalidReportUrls = draft.value.report.urls.filter((u) => !isValidUrl(u))
if (invalidReportUrls.length > 0) errors.push(t('admin.ops.webhookNotification.validation.invalidUrls'))
if (draft.value.alert.timeout_seconds < 1 || draft.value.alert.timeout_seconds > 60) {
errors.push(t('admin.ops.webhookNotification.validation.timeoutRange'))
}
if (draft.value.alert.rate_limit_per_hour < 0) {
errors.push(t('admin.ops.webhookNotification.validation.rateLimitRange'))
}
return { valid: errors.length === 0, errors }
})
onMounted(loadConfig)
</script>
<template>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ t('admin.ops.webhookNotification.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.description') }}
</p>
</div>
<button
class="btn btn-secondary text-sm"
@click="openEditor"
:disabled="loading || !config"
>
{{ t('common.edit') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-gray-500">
{{ t('admin.ops.webhookNotification.loading') }}
</div>
<div v-else-if="config" class="space-y-4">
<!-- Alert Webhook Status -->
<div class="flex items-center justify-between border-b border-gray-100 pb-3 dark:border-gray-700">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.alertTitle') }}</span>
<span
:class="config.alert.enabled && config.alert.urls.length > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-400'"
class="text-sm"
>
{{ config.alert.enabled && config.alert.urls.length > 0 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<div v-if="config.alert.enabled && config.alert.urls.length > 0" class="text-sm text-gray-600 dark:text-gray-400">
<div class="mb-1">{{ config.alert.urls.length }} webhook(s) configured</div>
<div class="text-xs">Min severity: {{ config.alert.min_severity || 'all' }}</div>
</div>
<!-- Report Webhook Status -->
<div class="flex items-center justify-between border-b border-gray-100 pb-3 dark:border-gray-700">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.reportTitle') }}</span>
<span
:class="config.report.enabled && config.report.urls.length > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-400'"
class="text-sm"
>
{{ config.report.enabled && config.report.urls.length > 0 ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div>
<!-- Editor Dialog -->
<BaseDialog
:show="showEditor"
:title="t('admin.ops.webhookNotification.title')"
width="wide"
@close="showEditor = false"
>
<div v-if="draft" class="space-y-6">
<!-- Alert Webhooks -->
<div>
<h4 class="mb-3 font-medium text-gray-900 dark:text-gray-100">
{{ t('admin.ops.webhookNotification.alertTitle') }}
</h4>
<div class="space-y-3">
<label class="inline-flex items-center gap-2">
<input v-model="draft.alert.enabled" type="checkbox" class="checkbox" />
<span class="text-sm">{{ t('common.enable') }}</span>
</label>
<div v-if="draft.alert.enabled">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.urls') }}
</label>
<div class="flex gap-2">
<input
v-model="alertUrlInput"
type="url"
class="input flex-1"
:placeholder="'https://example.com/webhook'"
@keyup.enter="addAlertUrl"
/>
<button type="button" class="btn btn-secondary" @click="addAlertUrl">
{{ t('common.add') }}
</button>
</div>
<div v-if="draft.alert.urls.length > 0" class="mt-2 space-y-1">
<div
v-for="(url, index) in draft.alert.urls"
:key="index"
class="flex items-center justify-between rounded bg-gray-50 px-2 py-1 text-sm dark:bg-gray-700"
>
<span class="truncate text-gray-700 dark:text-gray-300">{{ url }}</span>
<button
type="button"
class="text-gray-400 hover:text-red-500"
@click="removeAlertUrl(index)"
>
×
</button>
</div>
</div>
</div>
<div v-if="draft.alert.enabled" class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.minSeverity') }}
</label>
<Select
v-model="draft.alert.min_severity"
:options="severityOptions"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.timeoutSeconds') }}
</label>
<input v-model.number="draft.alert.timeout_seconds" type="number" min="1" max="60" class="input w-full" />
</div>
</div>
<div v-if="draft.alert.enabled" class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.rateLimitPerHour') }}
</label>
<input v-model.number="draft.alert.rate_limit_per_hour" type="number" min="0" class="input w-full" />
</div>
<label class="inline-flex items-center gap-2">
<input v-model="draft.alert.include_resolved" type="checkbox" class="checkbox" />
<span class="text-sm">{{ t('admin.ops.webhookNotification.includeResolved') }}</span>
</label>
</div>
<div v-if="draft.alert.enabled">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.secret') }}
</label>
<input
v-model="draft.alert.secret"
type="password"
class="input w-full"
:placeholder="t('admin.ops.webhookNotification.secretHint')"
/>
</div>
</div>
</div>
<!-- Report Webhooks -->
<div>
<h4 class="mb-3 font-medium text-gray-900 dark:text-gray-100">
{{ t('admin.ops.webhookNotification.reportTitle') }}
</h4>
<div class="space-y-3">
<label class="inline-flex items-center gap-2">
<input v-model="draft.report.enabled" type="checkbox" class="checkbox" />
<span class="text-sm">{{ t('common.enable') }}</span>
</label>
<div v-if="draft.report.enabled">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.urls') }}
</label>
<div class="flex gap-2">
<input
v-model="reportUrlInput"
type="url"
class="input flex-1"
:placeholder="'https://example.com/webhook'"
@keyup.enter="addReportUrl"
/>
<button type="button" class="btn btn-secondary" @click="addReportUrl">
{{ t('common.add') }}
</button>
</div>
<div v-if="draft.report.urls.length > 0" class="mt-2 space-y-1">
<div
v-for="(url, index) in draft.report.urls"
:key="index"
class="flex items-center justify-between rounded bg-gray-50 px-2 py-1 text-sm dark:bg-gray-700"
>
<span class="truncate text-gray-700 dark:text-gray-300">{{ url }}</span>
<button
type="button"
class="text-gray-400 hover:text-red-500"
@click="removeReportUrl(index)"
>
×
</button>
</div>
</div>
</div>
<div v-if="draft.report.enabled">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.ops.webhookNotification.secret') }}
</label>
<input
v-model="draft.report.secret"
type="password"
class="input w-full"
:placeholder="t('admin.ops.webhookNotification.secretHint')"
/>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="showEditor = false">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="saving || !editorValidation.valid"
@click="saveConfig"
>
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OpsSettingsDialog from '../OpsSettingsDialog.vue'
const mockGetAlertRuntimeSettings = vi.fn()
const mockGetEmailNotificationConfig = vi.fn()
const mockGetWebhookNotificationConfig = vi.fn()
const mockGetAdvancedSettings = vi.fn()
const mockGetMetricThresholds = vi.fn()
vi.mock('@/api/admin/ops', () => ({
opsAPI: {
getAlertRuntimeSettings: () => mockGetAlertRuntimeSettings(),
getEmailNotificationConfig: () => mockGetEmailNotificationConfig(),
getWebhookNotificationConfig: () => mockGetWebhookNotificationConfig(),
getAdvancedSettings: () => mockGetAdvancedSettings(),
getMetricThresholds: () => mockGetMetricThresholds(),
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
}),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
const defaultRuntimeSettings = {
evaluation_interval_seconds: 300,
}
const defaultEmailConfig = {
alert: {
enabled: false,
recipients: [],
min_severity: 'critical',
},
report: {
enabled: false,
recipients: [],
daily_summary_enabled: false,
daily_summary_schedule: '',
weekly_summary_enabled: false,
weekly_summary_schedule: '',
},
}
const defaultWebhookConfig = {
alert: {
enabled: false,
urls: [],
secret: '',
min_severity: 'critical',
timeout_seconds: 10,
include_resolved: false,
rate_limit_per_hour: 60,
},
report: {
enabled: false,
urls: [],
secret: '',
daily_enabled: false,
daily_schedule: '0 9 * * *',
},
}
const defaultAdvancedSettings = {
data_retention: {
cleanup_enabled: true,
cleanup_schedule: '0 2 * * *',
error_log_retention_days: 30,
minute_metrics_retention_days: 7,
hourly_metrics_retention_days: 90,
},
aggregation: {
aggregation_enabled: true,
},
ignore_count_tokens_errors: true,
ignore_context_canceled: true,
ignore_no_available_accounts: false,
ignore_invalid_api_key_errors: true,
ignore_insufficient_balance_errors: true,
auto_refresh_enabled: true,
auto_refresh_interval_seconds: 30,
display_alert_events: true,
display_openai_token_stats: true,
}
const defaultMetricThresholds = {
sla_percent_min: 99.5,
ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5,
}
describe('OpsSettingsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAlertRuntimeSettings.mockResolvedValue(defaultRuntimeSettings)
mockGetEmailNotificationConfig.mockResolvedValue(defaultEmailConfig)
mockGetWebhookNotificationConfig.mockResolvedValue(defaultWebhookConfig)
mockGetAdvancedSettings.mockResolvedValue(defaultAdvancedSettings)
mockGetMetricThresholds.mockResolvedValue(defaultMetricThresholds)
})
it('does not load settings when show is false', async () => {
mount(OpsSettingsDialog, {
props: { show: false },
global: {
stubs: {
BaseDialog: {
template: '<div v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
await flushPromises()
expect(mockGetAlertRuntimeSettings).not.toHaveBeenCalled()
})
it('loads settings when show changes from false to true', async () => {
const wrapper = mount(OpsSettingsDialog, {
props: { show: false },
global: {
stubs: {
BaseDialog: {
template: '<div v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
await flushPromises()
expect(mockGetAlertRuntimeSettings).not.toHaveBeenCalled()
await wrapper.setProps({ show: true })
await flushPromises()
expect(mockGetAlertRuntimeSettings).toHaveBeenCalled()
expect(mockGetWebhookNotificationConfig).toHaveBeenCalled()
})
it('handles API error on load gracefully', async () => {
mockGetWebhookNotificationConfig.mockRejectedValue(new Error('Load failed'))
const wrapper = mount(OpsSettingsDialog, {
props: { show: true },
global: {
stubs: {
BaseDialog: {
template: '<div v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
// Wait for watch to trigger and API call to complete
await flushPromises()
await flushPromises()
// Component should not crash
expect(wrapper.exists()).toBe(true)
})
})

View File

@@ -12,6 +12,7 @@ export type {
MetricType,
Operator,
EmailNotificationConfig,
WebhookNotificationConfig,
OpsDistributedLockSettings,
OpsAlertRuntimeSettings,
OpsMetricThresholds,