Commit 839ab37d authored by yangjianbo's avatar yangjianbo
Browse files
parents 9dd0ef18 fd8473f2
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
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 } from '../types'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const loading = ref(false)
const saving = ref(false)
// 运行时设置
const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
// 邮件通知配置
const emailConfig = ref<EmailNotificationConfig | null>(null)
// 高级设置
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 加载所有配置
async function loadAllSettings() {
loading.value = true
try {
const [runtime, email, advanced] = await Promise.all([
opsAPI.getAlertRuntimeSettings(),
opsAPI.getEmailNotificationConfig(),
opsAPI.getAdvancedSettings()
])
runtimeSettings.value = runtime
emailConfig.value = email
advancedSettings.value = advanced
} catch (err: any) {
console.error('[OpsSettingsDialog] Failed to load settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.loadFailed'))
} finally {
loading.value = false
}
}
// 监听弹窗打开
watch(() => props.show, (show) => {
if (show) {
loadAllSettings()
}
})
// 邮件输入
const alertRecipientInput = ref('')
const reportRecipientInput = ref('')
// 严重级别选项
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
{ value: '', label: t('admin.ops.email.minSeverityAll') },
{ value: 'critical', label: t('common.critical') },
{ value: 'warning', label: t('common.warning') },
{ value: 'info', label: t('common.info') }
]
// 验证邮箱
function isValidEmailAddress(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// 添加收件人
function addRecipient(target: 'alert' | 'report') {
if (!emailConfig.value) return
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
if (!raw) return
if (!isValidEmailAddress(raw)) {
appStore.showError(t('common.invalidEmail'))
return
}
const normalized = raw.toLowerCase()
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
if (!list.includes(normalized)) {
list.push(normalized)
}
if (target === 'alert') alertRecipientInput.value = ''
else reportRecipientInput.value = ''
}
// 移除收件人
function removeRecipient(target: 'alert' | 'report', email: string) {
if (!emailConfig.value) return
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
const idx = list.indexOf(email)
if (idx >= 0) list.splice(idx, 1)
}
// 验证
const validation = computed(() => {
const errors: string[] = []
// 验证运行时设置
if (runtimeSettings.value) {
const evalSeconds = runtimeSettings.value.evaluation_interval_seconds
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
}
}
// 验证邮件配置
if (emailConfig.value) {
if (emailConfig.value.alert.enabled && emailConfig.value.alert.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
}
if (emailConfig.value.report.enabled && emailConfig.value.report.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
}
}
// 验证高级设置
if (advancedSettings.value) {
const { error_log_retention_days, minute_metrics_retention_days, hourly_metrics_retention_days } = advancedSettings.value.data_retention
if (error_log_retention_days < 1 || error_log_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
if (minute_metrics_retention_days < 1 || minute_metrics_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
if (hourly_metrics_retention_days < 1 || hourly_metrics_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
}
return { valid: errors.length === 0, errors }
})
// 保存所有配置
async function saveAllSettings() {
if (!validation.value.valid) {
appStore.showError(validation.value.errors[0])
return
}
saving.value = true
try {
await Promise.all([
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve()
])
appStore.showSuccess(t('admin.ops.settings.saveSuccess'))
emit('saved')
emit('close')
} catch (err: any) {
console.error('[OpsSettingsDialog] Failed to save settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.saveFailed'))
} finally {
saving.value = false
}
}
</script>
<template>
<BaseDialog :show="show" :title="t('admin.ops.settings.title')" width="extra-wide" @close="emit('close')">
<div v-if="loading" class="py-10 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</div>
<div v-else-if="runtimeSettings && emailConfig && 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>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li v-for="msg in validation.errors" :key="msg">{{ msg }}</li>
</ul>
</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.dataCollection') }}</h4>
<div>
<label class="input-label">{{ t('admin.ops.settings.evaluationInterval') }}</label>
<input
v-model.number="runtimeSettings.evaluation_interval_seconds"
type="number"
min="1"
max="86400"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.evaluationIntervalHint') }}</p>
</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.alertConfig') }}</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableAlert') }}</label>
</div>
<Toggle v-model="emailConfig.alert.enabled" />
</div>
<div v-if="emailConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.settings.alertRecipients') }}</label>
<div class="flex gap-2">
<input
v-model="alertRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.settings.emailPlaceholder')"
@keydown.enter.prevent="addRecipient('alert')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
{{ t('common.add') }}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in emailConfig.alert.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('alert', email)">×</button>
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.settings.recipientsHint') }}
</p>
</div>
<div v-if="emailConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.settings.minSeverity') }}</label>
<Select v-model="emailConfig.alert.min_severity" :options="severityOptions" />
</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.reportConfig') }}</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableReport') }}</label>
</div>
<Toggle v-model="emailConfig.report.enabled" />
</div>
<div v-if="emailConfig.report.enabled">
<label class="input-label">{{ t('admin.ops.settings.reportRecipients') }}</label>
<div class="flex gap-2">
<input
v-model="reportRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.settings.emailPlaceholder')"
@keydown.enter.prevent="addRecipient('report')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
{{ t('common.add') }}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in emailConfig.report.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('report', email)">×</button>
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.settings.recipientsHint') }}
</p>
</div>
<div v-if="emailConfig.report.enabled" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dailySummary') }}</label>
<Toggle v-model="emailConfig.report.daily_summary_enabled" />
</div>
<div v-if="emailConfig.report.daily_summary_enabled">
<input v-model="emailConfig.report.daily_summary_schedule" type="text" class="input" placeholder="0 9 * * *" />
</div>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.weeklySummary') }}</label>
<Toggle v-model="emailConfig.report.weekly_summary_enabled" />
</div>
<div v-if="emailConfig.report.weekly_summary_enabled">
<input v-model="emailConfig.report.weekly_summary_schedule" type="text" class="input" placeholder="0 9 * * 1" />
</div>
</div>
</div>
</div>
<!-- 高级设置 -->
<details class="rounded-2xl bg-gray-50 dark:bg-dark-700/50">
<summary class="cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.ops.settings.advancedSettings') }}
</summary>
<div class="space-y-4 px-4 pb-4">
<!-- 数据保留策略 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dataRetention') }}</h5>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableCleanup') }}</label>
<Toggle v-model="advancedSettings.data_retention.cleanup_enabled" />
</div>
<div v-if="advancedSettings.data_retention.cleanup_enabled">
<label class="input-label">{{ t('admin.ops.settings.cleanupSchedule') }}</label>
<input
v-model="advancedSettings.data_retention.cleanup_schedule"
type="text"
class="input"
placeholder="0 2 * * *"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.cleanupScheduleHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.ops.settings.errorLogRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.error_log_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.minuteMetricsRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.minute_metrics_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.hourlyMetricsRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.hourly_metrics_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
</div>
<p class="text-xs text-gray-500">{{ t('admin.ops.settings.retentionDaysHint') }}</p>
</div>
<!-- 预聚合任务 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.aggregation') }}</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableAggregation') }}</label>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.aggregationHint') }}</p>
</div>
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
</div>
</div>
</div>
</details>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="emit('close')">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="saving || !validation.valid" @click="saveAllSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, CategoryScale, Filler, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import { Line } from 'vue-chartjs'
import type { ChartComponentRef } from 'vue-chartjs'
import type { OpsThroughputGroupBreakdownItem, OpsThroughputPlatformBreakdownItem, OpsThroughputTrendPoint } from '@/api/admin/ops'
import type { ChartState } from '../types'
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { formatNumber } from '@/utils/format'
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
interface Props {
points: OpsThroughputTrendPoint[]
loading: boolean
timeRange: string
byPlatform?: OpsThroughputPlatformBreakdownItem[]
topGroups?: OpsThroughputGroupBreakdownItem[]
}
const props = defineProps<Props>()
const { t } = useI18n()
const emit = defineEmits<{
(e: 'selectPlatform', platform: string): void
(e: 'selectGroup', groupId: number): void
(e: 'openDetails'): void
}>()
const throughputChartRef = ref<ChartComponentRef | null>(null)
watch(
() => props.timeRange,
() => {
setTimeout(() => {
const chart: any = throughputChartRef.value?.chart
if (chart && typeof chart.resetZoom === 'function') {
chart.resetZoom()
}
}, 100)
}
)
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
const colors = computed(() => ({
blue: '#3b82f6',
blueAlpha: '#3b82f620',
green: '#10b981',
greenAlpha: '#10b98120',
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const totalRequests = computed(() => sumNumbers(props.points.map((p) => p.request_count)))
const chartData = computed(() => {
if (!props.points.length || totalRequests.value <= 0) return null
return {
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
datasets: [
{
label: t('admin.ops.qps'),
data: props.points.map((p) => p.qps ?? 0),
borderColor: colors.value.blue,
backgroundColor: colors.value.blueAlpha,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHitRadius: 10
},
{
label: t('admin.ops.tpsK'),
data: props.points.map((p) => (p.tps ?? 0) / 1000),
borderColor: colors.value.green,
backgroundColor: colors.value.greenAlpha,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHitRadius: 10,
yAxisID: 'y1'
}
]
}
})
const state = computed<ChartState>(() => {
if (chartData.value) return 'ready'
if (props.loading) return 'loading'
return 'empty'
})
const options = computed(() => {
const c = colors.value
return {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' as const },
plugins: {
legend: {
position: 'top' as const,
align: 'end' as const,
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
},
tooltip: {
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
borderColor: c.grid,
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label: (context: any) => {
let label = context.dataset.label || ''
if (label) label += ': '
if (context.raw !== null) label += context.parsed.y.toFixed(1)
return label
}
}
},
// Optional: if chartjs-plugin-zoom is installed, these options will enable zoom/pan.
zoom: {
pan: { enabled: true, mode: 'x' as const, modifierKey: 'ctrl' as const },
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' as const }
}
},
scales: {
x: {
type: 'category' as const,
grid: { display: false },
ticks: {
color: c.text,
font: { size: 10 },
maxTicksLimit: 8,
autoSkip: true,
autoSkipPadding: 10
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: { color: c.grid, borderDash: [4, 4] },
ticks: { color: c.text, font: { size: 10 } }
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
grid: { display: false },
ticks: { color: c.green, font: { size: 10 } }
}
}
}
})
function resetZoom() {
const chart: any = throughputChartRef.value?.chart
if (chart && typeof chart.resetZoom === 'function') chart.resetZoom()
}
function downloadChart() {
const chart: any = throughputChartRef.value?.chart
if (!chart || typeof chart.toBase64Image !== 'function') return
const url = chart.toBase64Image('image/png', 1)
const a = document.createElement('a')
a.href = url
a.download = `ops-throughput-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`
a.click()
}
</script>
<template>
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="mb-4 flex shrink-0 items-center justify-between">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
{{ t('admin.ops.throughputTrend') }}
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" />
</h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
<button
type="button"
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="state !== 'ready'"
:title="t('admin.ops.requestDetails.title')"
@click="emit('openDetails')"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
<button
type="button"
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="state !== 'ready'"
:title="t('admin.ops.charts.resetZoomHint')"
@click="resetZoom"
>
{{ t('admin.ops.charts.resetZoom') }}
</button>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="state !== 'ready'"
:title="t('admin.ops.charts.downloadChartHint')"
@click="downloadChart"
>
{{ t('admin.ops.charts.downloadChart') }}
</button>
</div>
</div>
<!-- Drilldown chips (baseline interaction: click to set global filter) -->
<div v-if="(props.topGroups?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
<button
v-for="g in props.topGroups"
:key="g.group_id"
type="button"
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
@click="emit('selectGroup', g.group_id)"
>
<span class="max-w-[180px] truncate">{{ g.group_name || `#${g.group_id}` }}</span>
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(g.request_count) }}</span>
</button>
</div>
<div v-else-if="(props.byPlatform?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
<button
v-for="p in props.byPlatform"
:key="p.platform"
type="button"
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
@click="emit('selectPlatform', p.platform)"
>
<span class="uppercase">{{ p.platform }}</span>
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(p.request_count) }}</span>
</button>
</div>
<div class="min-h-0 flex-1">
<Line v-if="state === 'ready' && chartData" ref="throughputChartRef" :data="chartData" :options="options" />
<div v-else class="flex h-full items-center justify-center">
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
</div>
</div>
</div>
</template>
// Ops 前端视图层的共享类型(与后端 DTO 解耦)。
export type ChartState = 'loading' | 'empty' | 'ready'
// Re-export ops alert/settings types so view components can import from a single place
// while keeping the API contract centralized in `@/api/admin/ops`.
export type {
AlertRule,
AlertEvent,
AlertSeverity,
ThresholdMode,
MetricType,
Operator,
EmailNotificationConfig,
OpsDistributedLockSettings,
OpsAlertRuntimeSettings,
OpsAdvancedSettings,
OpsDataRetentionSettings,
OpsAggregationSettings
} from '@/api/admin/ops'
/**
* Ops 页面共享的格式化/样式工具。
*
* 目标:尽量对齐 `docs/sub2api` 备份版本的视觉表现(需求一致部分保持一致),
* 同时避免引入额外 UI 依赖。
*/
import type { OpsSeverity } from '@/api/admin/ops'
import { formatBytes } from '@/utils/format'
export function getSeverityClass(severity: OpsSeverity): string {
const classes: Record<string, string> = {
P0: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
}
return classes[String(severity || '')] || classes.P3
}
export function truncateMessage(msg: string, maxLength = 80): string {
if (!msg) return ''
return msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg
}
/**
* 格式化日期时间(短格式,和旧 Ops 页面一致)。
* 输出: `MM-DD HH:mm:ss`
*/
export function formatDateTime(dateStr: string): string {
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return ''
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
export function sumNumbers(values: Array<number | null | undefined>): number {
return values.reduce<number>((acc, v) => {
const n = typeof v === 'number' && Number.isFinite(v) ? v : 0
return acc + n
}, 0)
}
/**
* 解析 time_range 为分钟数。
* 支持:`5m/30m/1h/6h/24h`
*/
export function parseTimeRangeMinutes(range: string): number {
const trimmed = (range || '').trim()
if (!trimmed) return 60
if (trimmed.endsWith('m')) {
const v = Number.parseInt(trimmed.slice(0, -1), 10)
return Number.isFinite(v) && v > 0 ? v : 60
}
if (trimmed.endsWith('h')) {
const v = Number.parseInt(trimmed.slice(0, -1), 10)
return Number.isFinite(v) && v > 0 ? v * 60 : 60
}
return 60
}
export function formatHistoryLabel(date: string | undefined, timeRange: string): string {
if (!date) return ''
const d = new Date(date)
if (Number.isNaN(d.getTime())) return ''
const minutes = parseTimeRangeMinutes(timeRange)
if (minutes >= 24 * 60) {
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export function formatByteRate(bytes: number, windowMinutes: number): string {
const seconds = Math.max(1, (windowMinutes || 1) * 60)
return `${formatBytes(bytes / seconds, 1)}/s`
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment