feat(ops): 优化运维监控界面组件功能和交互

This commit is contained in:
IanShaw027
2026-01-11 20:56:36 +08:00
parent 2548800c3f
commit 92eafbc2a6
3 changed files with 245 additions and 81 deletions

View File

@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import { adminAPI } from '@/api'
import { opsAPI } from '@/api/admin/ops'
import type { AlertRule, MetricType, Operator } from '../types'
import type { OpsSeverity } from '@/api/admin/ops'
@@ -31,6 +32,7 @@ async function load() {
onMounted(() => {
load()
loadGroups()
})
const sortedRules = computed(() => {
@@ -44,29 +46,214 @@ const draft = ref<AlertRule | null>(null)
type MetricGroup = 'system' | 'group' | 'account'
interface MetricDefinition {
type: MetricType
group: MetricGroup
label: string
description: string
recommendedOperator: Operator
recommendedThreshold: number
unit?: string
}
const groupMetricTypes = new Set<MetricType>([
'group_available_accounts',
'group_available_ratio',
'group_rate_limit_ratio'
])
function parsePositiveInt(value: unknown): number | null {
if (value == null) return null
if (typeof value === 'boolean') return null
const n = typeof value === 'number' ? value : Number.parseInt(String(value), 10)
return Number.isFinite(n) && n > 0 ? n : null
}
const groupOptionsBase = ref<SelectOption[]>([])
async function loadGroups() {
try {
const list = await adminAPI.groups.getAll()
groupOptionsBase.value = list.map((g) => ({ value: g.id, label: g.name }))
} catch (err) {
console.error('[OpsAlertRulesCard] Failed to load groups', err)
groupOptionsBase.value = []
}
}
const isGroupMetricSelected = computed(() => {
const metricType = draft.value?.metric_type
return metricType ? groupMetricTypes.has(metricType) : false
})
const draftGroupId = computed<number | null>({
get() {
return parsePositiveInt(draft.value?.filters?.group_id)
},
set(value) {
if (!draft.value) return
if (value == null) {
if (!draft.value.filters) return
delete draft.value.filters.group_id
if (Object.keys(draft.value.filters).length === 0) {
delete draft.value.filters
}
return
}
if (!draft.value.filters) draft.value.filters = {}
draft.value.filters.group_id = value
}
})
const groupOptions = computed<SelectOption[]>(() => {
if (isGroupMetricSelected.value) return groupOptionsBase.value
return [{ value: null, label: t('admin.ops.alertRules.form.allGroups') }, ...groupOptionsBase.value]
})
const metricDefinitions = computed(() => {
return [
// System-level metrics
{ type: 'success_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.successRate') },
{ type: 'error_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.errorRate') },
{ type: 'upstream_error_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.upstreamErrorRate') },
{ type: 'p95_latency_ms' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.p95') },
{ type: 'p99_latency_ms' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.p99') },
{ type: 'cpu_usage_percent' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.cpu') },
{ type: 'memory_usage_percent' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.memory') },
{ type: 'concurrency_queue_depth' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.queueDepth') },
{
type: 'success_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.successRate'),
description: t('admin.ops.alertRules.metricDescriptions.successRate'),
recommendedOperator: '<',
recommendedThreshold: 99,
unit: '%'
},
{
type: 'error_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.errorRate'),
description: t('admin.ops.alertRules.metricDescriptions.errorRate'),
recommendedOperator: '>',
recommendedThreshold: 1,
unit: '%'
},
{
type: 'upstream_error_rate',
group: 'system',
label: t('admin.ops.alertRules.metrics.upstreamErrorRate'),
description: t('admin.ops.alertRules.metricDescriptions.upstreamErrorRate'),
recommendedOperator: '>',
recommendedThreshold: 1,
unit: '%'
},
{
type: 'p95_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p95'),
description: t('admin.ops.alertRules.metricDescriptions.p95'),
recommendedOperator: '>',
recommendedThreshold: 1000,
unit: 'ms'
},
{
type: 'p99_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p99'),
description: t('admin.ops.alertRules.metricDescriptions.p99'),
recommendedOperator: '>',
recommendedThreshold: 2000,
unit: 'ms'
},
{
type: 'cpu_usage_percent',
group: 'system',
label: t('admin.ops.alertRules.metrics.cpu'),
description: t('admin.ops.alertRules.metricDescriptions.cpu'),
recommendedOperator: '>',
recommendedThreshold: 80,
unit: '%'
},
{
type: 'memory_usage_percent',
group: 'system',
label: t('admin.ops.alertRules.metrics.memory'),
description: t('admin.ops.alertRules.metricDescriptions.memory'),
recommendedOperator: '>',
recommendedThreshold: 80,
unit: '%'
},
{
type: 'concurrency_queue_depth',
group: 'system',
label: t('admin.ops.alertRules.metrics.queueDepth'),
description: t('admin.ops.alertRules.metricDescriptions.queueDepth'),
recommendedOperator: '>',
recommendedThreshold: 10
},
// Group-level metrics (requires group_id filter)
{ type: 'group_available_accounts' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupAvailableAccounts') },
{ type: 'group_available_ratio' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupAvailableRatio') },
{ type: 'group_rate_limit_ratio' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupRateLimitRatio') },
{
type: 'group_available_accounts',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupAvailableAccounts'),
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableAccounts'),
recommendedOperator: '<',
recommendedThreshold: 1
},
{
type: 'group_available_ratio',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupAvailableRatio'),
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableRatio'),
recommendedOperator: '<',
recommendedThreshold: 50,
unit: '%'
},
{
type: 'group_rate_limit_ratio',
group: 'group',
label: t('admin.ops.alertRules.metrics.groupRateLimitRatio'),
description: t('admin.ops.alertRules.metricDescriptions.groupRateLimitRatio'),
recommendedOperator: '>',
recommendedThreshold: 10,
unit: '%'
},
// Account-level metrics
{ type: 'account_rate_limited_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountRateLimitedCount') },
{ type: 'account_error_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountErrorCount') },
{ type: 'account_error_ratio' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountErrorRatio') },
{ type: 'overload_account_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.overloadAccountCount') }
] satisfies Array<{ type: MetricType; group: MetricGroup; label: string }>
{
type: 'account_rate_limited_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountRateLimitedCount'),
description: t('admin.ops.alertRules.metricDescriptions.accountRateLimitedCount'),
recommendedOperator: '>',
recommendedThreshold: 0
},
{
type: 'account_error_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountErrorCount'),
description: t('admin.ops.alertRules.metricDescriptions.accountErrorCount'),
recommendedOperator: '>',
recommendedThreshold: 0
},
{
type: 'account_error_ratio',
group: 'account',
label: t('admin.ops.alertRules.metrics.accountErrorRatio'),
description: t('admin.ops.alertRules.metricDescriptions.accountErrorRatio'),
recommendedOperator: '>',
recommendedThreshold: 5,
unit: '%'
},
{
type: 'overload_account_count',
group: 'account',
label: t('admin.ops.alertRules.metrics.overloadAccountCount'),
description: t('admin.ops.alertRules.metricDescriptions.overloadAccountCount'),
recommendedOperator: '>',
recommendedThreshold: 0
}
] satisfies MetricDefinition[]
})
const selectedMetricDefinition = computed(() => {
const metricType = draft.value?.metric_type
if (!metricType) return null
return metricDefinitions.value.find((m) => m.type === metricType) ?? null
})
const metricOptions = computed(() => {
@@ -137,6 +324,9 @@ const editorValidation = computed(() => {
if (!r) return { valid: true, errors }
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
if (groupMetricTypes.has(r.metric_type) && !parsePositiveInt(r.filters?.group_id)) {
errors.push(t('admin.ops.alertRules.validation.groupIdRequired'))
}
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
@@ -321,6 +511,18 @@ function cancelDelete() {
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
<Select v-model="draft!.metric_type" :options="metricOptions" />
<div v-if="selectedMetricDefinition" class="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<p>{{ selectedMetricDefinition.description }}</p>
<p>
{{
t('admin.ops.alertRules.hints.recommended', {
operator: selectedMetricDefinition.recommendedOperator,
threshold: selectedMetricDefinition.recommendedThreshold,
unit: selectedMetricDefinition.unit || ''
})
}}
</p>
</div>
</div>
<div>
@@ -328,6 +530,23 @@ function cancelDelete() {
<Select v-model="draft!.operator" :options="operatorOptions" />
</div>
<div class="md:col-span-2">
<label class="input-label">
{{ t('admin.ops.alertRules.form.groupId') }}
<span v-if="isGroupMetricSelected" class="ml-1 text-red-500">*</span>
</label>
<Select
v-model="draftGroupId"
:options="groupOptions"
searchable
:placeholder="t('admin.ops.alertRules.form.groupPlaceholder')"
:error="isGroupMetricSelected && !draftGroupId"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ isGroupMetricSelected ? t('admin.ops.alertRules.hints.groupRequired') : t('admin.ops.alertRules.hints.groupOptional') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
<input v-model.number="draft!.threshold" class="input" type="number" />

View File

@@ -239,47 +239,6 @@ const ttftP50Ms = computed(() => overview.value?.ttft?.p50_ms ?? null)
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
// --- WebSocket status ---
const wsStatusLabel = computed(() => {
switch (props.wsStatus) {
case 'connected':
return t('admin.ops.realtime.connected')
case 'connecting':
return t('admin.ops.realtime.connecting')
case 'reconnecting':
return t('admin.ops.realtime.reconnecting')
case 'offline':
return t('admin.ops.realtime.offline')
case 'closed':
default:
return t('admin.ops.realtime.closed')
}
})
const wsStatusDotClass = computed(() => {
switch (props.wsStatus) {
case 'connected':
return 'bg-green-500'
case 'reconnecting':
case 'connecting':
return 'bg-yellow-500'
case 'offline':
return 'bg-orange-500'
case 'closed':
default:
return 'bg-gray-400'
}
})
const wsReconnectHint = computed(() => {
if (props.wsStatus !== 'reconnecting') return ''
const delayMs = props.wsReconnectInMs ?? null
if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs <= 0) return ''
const sec = Math.max(1, Math.ceil(delayMs / 1000))
return t('admin.ops.realtime.reconnectIn', { seconds: sec })
})
// --- Health Score & Diagnosis (primary) ---
const isSystemIdle = computed(() => {
@@ -662,19 +621,14 @@ function openJobsDetails() {
<span>·</span>
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
<span>·</span>
<span class="flex items-center gap-1.5">
<span class="relative flex h-2 w-2">
<span
v-if="wsStatus === 'connected'"
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full" :class="wsStatusDotClass"></span>
<template v-if="systemMetrics">
<span>·</span>
<span>
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
</span>
<span>{{ wsStatusLabel }}</span>
<span v-if="wsReconnectHint" class="text-[11px] text-gray-400">({{ wsReconnectHint }})</span>
</span>
</template>
</div>
</div>
@@ -1189,14 +1143,6 @@ function openJobsDetails() {
<!-- Integrated: System health (cards) -->
<div v-if="overview" class="mt-2 border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="systemMetrics">
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
</span>
<span v-else>{{ t('admin.ops.noSystemMetrics') }}</span>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<!-- CPU -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">

View File

@@ -150,11 +150,10 @@ const kindBadgeClass = (kind: string) => {
<template>
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
<template #default>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
</div>
<div class="flex justify-end mb-4">
<div class="flex items-center justify-between mb-4">
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
</div>
<button
type="button"
class="btn btn-secondary btn-sm"