Files
xinghuoapi/frontend/src/views/admin/ops/components/OpsRuntimeSettingsCard.vue
IanShaw027 55e469c7fe fix(ops): 优化错误日志过滤和查询逻辑
后端改动:
- 添加 resolved 参数默认值处理(向后兼容,默认显示未解决错误)
- 新增 status_codes_other 查询参数支持
- 移除 service 层的高级设置过滤逻辑,简化错误日志查询流程

前端改动:
- 完善错误日志相关组件的国际化支持
- 优化 Ops 监控面板和设置对话框的用户体验
2026-01-14 16:26:33 +08:00

537 lines
23 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { opsAPI } from '@/api/admin/ops'
import type { OpsAlertRuntimeSettings } from '../types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const saving = ref(false)
const alertSettings = ref<OpsAlertRuntimeSettings | null>(null)
const showAlertEditor = ref(false)
const draftAlert = ref<OpsAlertRuntimeSettings | null>(null)
type ValidationResult = { valid: boolean; errors: string[] }
function normalizeSeverities(input: Array<string | null | undefined> | null | undefined): string[] {
if (!input || input.length === 0) return []
const allowed = new Set(['P0', 'P1', 'P2', 'P3'])
const out: string[] = []
const seen = new Set<string>()
for (const raw of input) {
const s = String(raw || '')
.trim()
.toUpperCase()
if (!s) continue
if (!allowed.has(s)) continue
if (seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
}
function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationResult {
const errors: string[] = []
const evalSeconds = settings.evaluation_interval_seconds
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
}
// Thresholds validation
const thresholds = settings.thresholds
if (thresholds) {
if (thresholds.sla_percent_min != null) {
if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) {
errors.push(t('admin.ops.runtime.validation.slaMinPercentRange'))
}
}
if (thresholds.ttft_p99_ms_max != null) {
if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) {
errors.push(t('admin.ops.runtime.validation.ttftP99MaxRange'))
}
}
if (thresholds.request_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) {
errors.push(t('admin.ops.runtime.validation.requestErrorRateMaxRange'))
}
}
if (thresholds.upstream_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) {
errors.push(t('admin.ops.runtime.validation.upstreamErrorRateMaxRange'))
}
}
}
const lock = settings.distributed_lock
if (lock?.enabled) {
if (!lock.key || lock.key.trim().length < 3) {
errors.push(t('admin.ops.runtime.validation.lockKeyRequired'))
} else if (!lock.key.startsWith('ops:')) {
errors.push(t('admin.ops.runtime.validation.lockKeyPrefix', { prefix: 'ops:' }))
}
if (!Number.isFinite(lock.ttl_seconds) || lock.ttl_seconds < 1 || lock.ttl_seconds > 86400) {
errors.push(t('admin.ops.runtime.validation.lockTtlRange'))
}
}
// Silencing validation (alert-only)
const silencing = settings.silencing
if (silencing?.enabled) {
const until = (silencing.global_until_rfc3339 || '').trim()
if (until) {
const parsed = Date.parse(until)
if (!Number.isFinite(parsed)) errors.push(t('admin.ops.runtime.silencing.validation.timeFormat'))
}
const entries = Array.isArray(silencing.entries) ? silencing.entries : []
for (let idx = 0; idx < entries.length; idx++) {
const entry = entries[idx]
const untilEntry = (entry?.until_rfc3339 || '').trim()
if (!untilEntry) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilRequired'))
break
}
const parsedEntry = Date.parse(untilEntry)
if (!Number.isFinite(parsedEntry)) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilFormat'))
break
}
const ruleId = (entry as any)?.rule_id
if (typeof ruleId === 'number' && (!Number.isFinite(ruleId) || ruleId <= 0)) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.ruleIdPositive'))
break
}
if ((entry as any)?.severities) {
const raw = (entry as any).severities
const normalized = normalizeSeverities(Array.isArray(raw) ? raw : [raw])
if (Array.isArray(raw) && raw.length > 0 && normalized.length === 0) {
errors.push(t('admin.ops.runtime.silencing.entries.validation.severitiesFormat'))
break
}
}
}
}
return { valid: errors.length === 0, errors }
}
const alertValidation = computed(() => {
if (!draftAlert.value) return { valid: true, errors: [] as string[] }
return validateRuntimeSettings(draftAlert.value)
})
async function loadSettings() {
loading.value = true
try {
alertSettings.value = await opsAPI.getAlertRuntimeSettings()
} catch (err: any) {
console.error('[OpsRuntimeSettingsCard] Failed to load runtime settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.loadFailed'))
} finally {
loading.value = false
}
}
function openAlertEditor() {
if (!alertSettings.value) return
draftAlert.value = JSON.parse(JSON.stringify(alertSettings.value))
// Backwards-compat: ensure nested settings exist even if API payload is older.
if (draftAlert.value) {
if (!draftAlert.value.distributed_lock) {
draftAlert.value.distributed_lock = { enabled: true, key: 'ops:alert:evaluator:leader', ttl_seconds: 30 }
}
if (!draftAlert.value.silencing) {
draftAlert.value.silencing = { enabled: false, global_until_rfc3339: '', global_reason: '', entries: [] }
}
if (!Array.isArray(draftAlert.value.silencing.entries)) {
draftAlert.value.silencing.entries = []
}
if (!draftAlert.value.thresholds) {
draftAlert.value.thresholds = {
sla_percent_min: 99.5,
ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5
}
}
}
showAlertEditor.value = true
}
function addSilenceEntry() {
if (!draftAlert.value) return
if (!draftAlert.value.silencing) {
draftAlert.value.silencing = { enabled: true, global_until_rfc3339: '', global_reason: '', entries: [] }
}
if (!Array.isArray(draftAlert.value.silencing.entries)) {
draftAlert.value.silencing.entries = []
}
draftAlert.value.silencing.entries.push({
rule_id: undefined,
severities: [],
until_rfc3339: '',
reason: ''
})
}
function removeSilenceEntry(index: number) {
if (!draftAlert.value?.silencing?.entries) return
draftAlert.value.silencing.entries.splice(index, 1)
}
function updateSilenceEntryRuleId(index: number, raw: string) {
const entries = draftAlert.value?.silencing?.entries
if (!entries || !entries[index]) return
const trimmed = raw.trim()
if (!trimmed) {
delete (entries[index] as any).rule_id
return
}
const n = Number.parseInt(trimmed, 10)
;(entries[index] as any).rule_id = Number.isFinite(n) ? n : undefined
}
function updateSilenceEntrySeverities(index: number, raw: string) {
const entries = draftAlert.value?.silencing?.entries
if (!entries || !entries[index]) return
const parts = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
;(entries[index] as any).severities = normalizeSeverities(parts)
}
async function saveAlertSettings() {
if (!draftAlert.value) return
if (!alertValidation.value.valid) {
appStore.showError(alertValidation.value.errors[0] || t('admin.ops.runtime.validation.invalid'))
return
}
saving.value = true
try {
alertSettings.value = await opsAPI.updateAlertRuntimeSettings(draftAlert.value)
showAlertEditor.value = false
appStore.showSuccess(t('admin.ops.runtime.saveSuccess'))
} catch (err: any) {
console.error('[OpsRuntimeSettingsCard] Failed to save alert runtime settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<template>
<div class="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 items-start justify-between gap-4">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.description') }}</p>
</div>
<button
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
@click="loadSettings"
>
<svg class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.refresh') }}
</button>
</div>
<div v-if="!alertSettings" class="text-sm text-gray-500 dark:text-gray-400">
<span v-if="loading">{{ t('admin.ops.runtime.loading') }}</span>
<span v-else>{{ t('admin.ops.runtime.noData') }}</span>
</div>
<div v-else class="space-y-6">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.alertTitle') }}</h4>
<button class="btn btn-sm btn-secondary" @click="openAlertEditor">{{ t('common.edit') }}</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ t('admin.ops.runtime.evalIntervalSeconds') }}:
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ alertSettings.evaluation_interval_seconds }}s</span>
</div>
<div
v-if="alertSettings.silencing?.enabled && alertSettings.silencing.global_until_rfc3339"
class="text-xs text-gray-600 dark:text-gray-300 md:col-span-2"
>
{{ t('admin.ops.runtime.silencing.globalUntil') }}:
<span class="ml-1 font-mono text-gray-900 dark:text-white">{{ alertSettings.silencing.global_until_rfc3339 }}</span>
</div>
<details class="col-span-1 md:col-span-2">
<summary class="cursor-pointer text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{{ t('admin.ops.runtime.showAdvancedDeveloperSettings') }}
</summary>
<div class="mt-2 grid grid-cols-1 gap-3 rounded-lg bg-gray-100 p-3 dark:bg-dark-800 md:grid-cols-2">
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockEnabled') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.enabled }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockKey') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.key }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.lockTTLSeconds') }}:
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.ttl_seconds }}s</span>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<BaseDialog :show="showAlertEditor" :title="t('admin.ops.runtime.alertTitle')" width="extra-wide" @close="showAlertEditor = false">
<div v-if="draftAlert" class="space-y-4">
<div
v-if="!alertValidation.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.runtime.validation.title') }}</div>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li v-for="msg in alertValidation.errors" :key="msg">{{ msg }}</li>
</ul>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.evalIntervalSeconds') }}</div>
<input
v-model.number="draftAlert.evaluation_interval_seconds"
type="number"
min="1"
max="86400"
class="input"
:aria-invalid="!alertValidation.valid"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.metricThresholds') }}</div>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.metricThresholdsHint') }}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.slaMinPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.sla_percent_min"
type="number"
min="0"
max="100"
step="0.1"
class="input"
placeholder="99.5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.slaMinPercentHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.ttftP99MaxMs') }}</div>
<input
v-model.number="draftAlert.thresholds.ttft_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
placeholder="500"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.ttftP99MaxMsHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.requestErrorRateMaxPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.request_error_rate_percent_max"
type="number"
min="0"
max="100"
step="0.1"
class="input"
placeholder="5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.requestErrorRateMaxPercentHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max"
type="number"
min="0"
max="100"
step="0.1"
class="input"
placeholder="5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercentHint') }}</p>
</div>
</div>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="draftAlert.silencing.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('admin.ops.runtime.silencing.enabled') }}</span>
</label>
<div v-if="draftAlert.silencing.enabled" class="mt-4 space-y-4">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.globalUntil') }}</div>
<input
v-model="draftAlert.silencing.global_until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.reason') }}</div>
<input
v-model="draftAlert.silencing.global_reason"
type="text"
class="input"
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
/>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.entries.title') }}</div>
<p class="text-[11px] text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.entries.hint') }}</p>
</div>
<button class="btn btn-sm btn-secondary" type="button" @click="addSilenceEntry">
{{ t('admin.ops.runtime.silencing.entries.add') }}
</button>
</div>
<div v-if="!draftAlert.silencing.entries?.length" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-900 dark:text-gray-400">
{{ t('admin.ops.runtime.silencing.entries.empty') }}
</div>
<div v-else class="mt-4 space-y-4">
<div
v-for="(entry, idx) in draftAlert.silencing.entries"
:key="idx"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="mb-3 flex items-center justify-between">
<div class="text-xs font-bold text-gray-900 dark:text-white">
{{ t('admin.ops.runtime.silencing.entries.entryTitle', { n: idx + 1 }) }}
</div>
<button class="btn btn-sm btn-danger" type="button" @click="removeSilenceEntry(idx)">{{ t('common.delete') }}</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.ruleId') }}</div>
<input
:value="typeof (entry as any).rule_id === 'number' ? String((entry as any).rule_id) : ''"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.entries.ruleIdPlaceholder')"
@input="updateSilenceEntryRuleId(idx, ($event.target as HTMLInputElement).value)"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.severities') }}</div>
<input
:value="Array.isArray((entry as any).severities) ? (entry as any).severities.join(', ') : ''"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.entries.severitiesPlaceholder')"
@input="updateSilenceEntrySeverities(idx, ($event.target as HTMLInputElement).value)"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.until') }}</div>
<input
v-model="(entry as any).until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
/>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.reason') }}</div>
<input
v-model="(entry as any).reason"
type="text"
class="input"
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<summary class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.ops.runtime.advancedSettingsSummary') }}</summary>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="inline-flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
<input v-model="draftAlert.distributed_lock.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('admin.ops.runtime.lockEnabled') }}</span>
</label>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockKey') }}</div>
<input v-model="draftAlert.distributed_lock.key" type="text" class="input text-xs font-mono" />
<p v-if="draftAlert.distributed_lock.enabled" class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.runtime.validation.lockKeyHint', { prefix: 'ops:' }) }}
</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockTTLSeconds') }}</div>
<input v-model.number="draftAlert.distributed_lock.ttl_seconds" type="number" min="1" max="86400" class="input text-xs font-mono" />
</div>
</div>
</details>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="showAlertEditor = false">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="saving || !alertValidation.valid" @click="saveAlertSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>