diff --git a/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue b/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue index bc6d4948..11c24162 100644 --- a/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue +++ b/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue @@ -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(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([ + '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([]) + +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({ + 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(() => { + 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() {
+
+ + diff --git a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue index fb622eaa..afc17813 100644 --- a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue +++ b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue @@ -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() { · {{ t('common.refresh') }}: {{ updatedAtLabel }} - · - - - - +
@@ -1189,14 +1143,6 @@ function openJobsDetails() {
-
- - {{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }} - ({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m) - - {{ t('admin.ops.noSystemMetrics') }} -
-
diff --git a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue index e1063bce..3044ee3a 100644 --- a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue +++ b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue @@ -150,11 +150,10 @@ const kindBadgeClass = (kind: string) => {