feat(ops): 优化运维监控界面组件功能和交互
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user