feat(ui): 优化ops监控面板和组件功能
- 增强告警事件卡片的交互和静默功能 - 完善错误详情弹窗的展示和操作 - 优化错误日志表格的筛选和排序 - 新增重试和解决状态的UI支持
This commit is contained in:
@@ -169,7 +169,13 @@ const QUERY_KEYS = {
|
||||
platform: 'platform',
|
||||
groupId: 'group_id',
|
||||
queryMode: 'mode',
|
||||
fullscreen: 'fullscreen'
|
||||
fullscreen: 'fullscreen',
|
||||
|
||||
// Deep links
|
||||
openErrorDetails: 'open_error_details',
|
||||
errorType: 'error_type',
|
||||
alertRuleId: 'alert_rule_id',
|
||||
openAlertRules: 'open_alert_rules'
|
||||
} as const
|
||||
|
||||
const isApplyingRouteQuery = ref(false)
|
||||
@@ -249,6 +255,24 @@ const applyRouteQueryToState = () => {
|
||||
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
|
||||
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
|
||||
}
|
||||
|
||||
// Deep links
|
||||
const openRules = readQueryString(QUERY_KEYS.openAlertRules)
|
||||
if (openRules === '1' || openRules === 'true') {
|
||||
showAlertRulesCard.value = true
|
||||
}
|
||||
|
||||
const ruleID = readQueryNumber(QUERY_KEYS.alertRuleId)
|
||||
if (typeof ruleID === 'number' && ruleID > 0) {
|
||||
showAlertRulesCard.value = true
|
||||
}
|
||||
|
||||
const openErr = readQueryString(QUERY_KEYS.openErrorDetails)
|
||||
if (openErr === '1' || openErr === 'true') {
|
||||
const typ = readQueryString(QUERY_KEYS.errorType)
|
||||
errorDetailsType.value = typ === 'upstream' ? 'upstream' : 'request'
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
}
|
||||
|
||||
applyRouteQueryToState()
|
||||
|
||||
@@ -3,42 +3,326 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { opsAPI, type AlertEventsQuery } from '@/api/admin/ops'
|
||||
import type { AlertEvent } from '../types'
|
||||
import { formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const events = ref<AlertEvent[]>([])
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const limit = ref(100)
|
||||
const limitOptions = computed(() => [
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 200, label: '200' }
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const events = ref<AlertEvent[]>([])
|
||||
const hasMore = ref(true)
|
||||
|
||||
// Detail modal
|
||||
const showDetail = ref(false)
|
||||
const selected = ref<AlertEvent | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailActionLoading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const history = ref<AlertEvent[]>([])
|
||||
const historyRange = ref('7d')
|
||||
const historyRangeOptions = computed(() => [
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
async function load() {
|
||||
const silenceDuration = ref('1h')
|
||||
const silenceDurationOptions = computed(() => [
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') }
|
||||
])
|
||||
|
||||
// Filters
|
||||
const timeRange = ref('24h')
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '5m', label: t('admin.ops.timeRange.5m') },
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '6h', label: t('admin.ops.timeRange.6h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
const severity = ref<string>('')
|
||||
const severityOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'P0', label: 'P0' },
|
||||
{ value: 'P1', label: 'P1' },
|
||||
{ value: 'P2', label: 'P2' },
|
||||
{ value: 'P3', label: 'P3' }
|
||||
])
|
||||
|
||||
const status = ref<string>('')
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'firing', label: t('admin.ops.alertEvents.status.firing') },
|
||||
{ value: 'resolved', label: t('admin.ops.alertEvents.status.resolved') },
|
||||
{ value: 'manual_resolved', label: t('admin.ops.alertEvents.status.manualResolved') }
|
||||
])
|
||||
|
||||
const emailSent = ref<string>('')
|
||||
const emailSentOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'true', label: t('admin.ops.alertEvents.table.emailSent') },
|
||||
{ value: 'false', label: t('admin.ops.alertEvents.table.emailIgnored') }
|
||||
])
|
||||
|
||||
function buildQuery(overrides: Partial<AlertEventsQuery> = {}): AlertEventsQuery {
|
||||
const q: AlertEventsQuery = {
|
||||
limit: PAGE_SIZE,
|
||||
time_range: timeRange.value
|
||||
}
|
||||
if (severity.value) q.severity = severity.value
|
||||
if (status.value) q.status = status.value
|
||||
if (emailSent.value === 'true') q.email_sent = true
|
||||
if (emailSent.value === 'false') q.email_sent = false
|
||||
return { ...q, ...overrides }
|
||||
}
|
||||
|
||||
async function loadFirstPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
events.value = await opsAPI.listAlertEvents(limit.value)
|
||||
const data = await opsAPI.listAlertEvents(buildQuery())
|
||||
events.value = data
|
||||
hasMore.value = data.length === PAGE_SIZE
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert events', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
|
||||
events.value = []
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || loading.value) return
|
||||
if (!hasMore.value) return
|
||||
const last = events.value[events.value.length - 1]
|
||||
if (!last) return
|
||||
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const data = await opsAPI.listAlertEvents(
|
||||
buildQuery({ before_fired_at: last.fired_at || last.created_at, before_id: last.id })
|
||||
)
|
||||
if (!data.length) {
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
events.value = [...events.value, ...data]
|
||||
if (data.length < PAGE_SIZE) hasMore.value = false
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load more alert events', err)
|
||||
hasMore.value = false
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
const el = e.target as HTMLElement | null
|
||||
if (!el) return
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 120
|
||||
if (nearBottom) loadMore()
|
||||
}
|
||||
|
||||
function getDimensionString(event: AlertEvent | null | undefined, key: string): string {
|
||||
const v = event?.dimensions?.[key]
|
||||
if (v == null) return ''
|
||||
if (typeof v === 'string') return v
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms))
|
||||
const sec = Math.floor(safe / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h`
|
||||
const day = Math.floor(hr / 24)
|
||||
return `${day}d`
|
||||
}
|
||||
|
||||
function formatDurationLabel(event: AlertEvent): string {
|
||||
const firedAt = new Date(event.fired_at || event.created_at)
|
||||
if (Number.isNaN(firedAt.getTime())) return '-'
|
||||
const resolvedAtStr = event.resolved_at || null
|
||||
const status = String(event.status || '').trim().toLowerCase()
|
||||
|
||||
if (resolvedAtStr) {
|
||||
const resolvedAt = new Date(resolvedAtStr)
|
||||
if (!Number.isNaN(resolvedAt.getTime())) {
|
||||
const ms = resolvedAt.getTime() - firedAt.getTime()
|
||||
const prefix = status === 'manual_resolved'
|
||||
? t('admin.ops.alertEvents.status.manualResolved')
|
||||
: t('admin.ops.alertEvents.status.resolved')
|
||||
return `${prefix} ${formatDurationMs(ms)}`
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const ms = now - firedAt.getTime()
|
||||
return `${t('admin.ops.alertEvents.status.firing')} ${formatDurationMs(ms)}`
|
||||
}
|
||||
|
||||
function formatDimensionsSummary(event: AlertEvent): string {
|
||||
const parts: string[] = []
|
||||
const platform = getDimensionString(event, 'platform')
|
||||
if (platform) parts.push(`platform=${platform}`)
|
||||
const groupId = event.dimensions?.group_id
|
||||
if (groupId != null && groupId !== '') parts.push(`group_id=${String(groupId)}`)
|
||||
const region = getDimensionString(event, 'region')
|
||||
if (region) parts.push(`region=${region}`)
|
||||
return parts.length ? parts.join(' ') : '-'
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
selected.value = null
|
||||
history.value = []
|
||||
}
|
||||
|
||||
async function openDetail(row: AlertEvent) {
|
||||
showDetail.value = true
|
||||
selected.value = row
|
||||
detailLoading.value = true
|
||||
historyLoading.value = true
|
||||
|
||||
try {
|
||||
const detail = await opsAPI.getAlertEvent(row.id)
|
||||
selected.value = detail
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert detail', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.loadFailed'))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const ev = selected.value
|
||||
if (!ev) {
|
||||
history.value = []
|
||||
historyLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const platform = getDimensionString(ev, 'platform')
|
||||
const groupIdRaw = ev.dimensions?.group_id
|
||||
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : undefined
|
||||
|
||||
const items = await opsAPI.listAlertEvents({
|
||||
limit: 20,
|
||||
time_range: historyRange.value,
|
||||
platform: platform || undefined,
|
||||
group_id: groupId,
|
||||
status: ''
|
||||
})
|
||||
|
||||
// Best-effort: narrow to same rule_id + dimensions
|
||||
history.value = items.filter((it) => {
|
||||
if (it.rule_id !== ev.rule_id) return false
|
||||
const p1 = getDimensionString(it, 'platform')
|
||||
const p2 = getDimensionString(ev, 'platform')
|
||||
if ((p1 || '') !== (p2 || '')) return false
|
||||
const g1 = it.dimensions?.group_id
|
||||
const g2 = ev.dimensions?.group_id
|
||||
return (g1 ?? null) === (g2 ?? null)
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert history', err)
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function durationToUntilRFC3339(duration: string): string {
|
||||
const now = Date.now()
|
||||
if (duration === '1h') return new Date(now + 60 * 60 * 1000).toISOString()
|
||||
if (duration === '24h') return new Date(now + 24 * 60 * 60 * 1000).toISOString()
|
||||
if (duration === '7d') return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
return new Date(now + 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
async function silenceAlert() {
|
||||
const ev = selected.value
|
||||
if (!ev) return
|
||||
if (detailActionLoading.value) return
|
||||
detailActionLoading.value = true
|
||||
try {
|
||||
const platform = getDimensionString(ev, 'platform')
|
||||
const groupIdRaw = ev.dimensions?.group_id
|
||||
const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : null
|
||||
const region = getDimensionString(ev, 'region') || null
|
||||
|
||||
await opsAPI.createAlertSilence({
|
||||
rule_id: ev.rule_id,
|
||||
platform: platform || '',
|
||||
group_id: groupId ?? undefined,
|
||||
region: region ?? undefined,
|
||||
until: durationToUntilRFC3339(silenceDuration.value),
|
||||
reason: `silence from UI (${silenceDuration.value})`
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.ops.alertEvents.detail.silenceSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to silence alert', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.silenceFailed'))
|
||||
} finally {
|
||||
detailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manualResolve() {
|
||||
if (!selected.value) return
|
||||
if (detailActionLoading.value) return
|
||||
detailActionLoading.value = true
|
||||
try {
|
||||
await opsAPI.updateAlertEventStatus(selected.value.id, 'manual_resolved')
|
||||
appStore.showSuccess(t('admin.ops.alertEvents.detail.manualResolvedSuccess'))
|
||||
|
||||
// Refresh detail + first page to reflect new status
|
||||
const detail = await opsAPI.getAlertEvent(selected.value.id)
|
||||
selected.value = detail
|
||||
await loadFirstPage()
|
||||
await loadHistory()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to resolve alert', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.manualResolvedFailed'))
|
||||
} finally {
|
||||
detailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadFirstPage()
|
||||
})
|
||||
|
||||
watch(limit, () => {
|
||||
load()
|
||||
watch([timeRange, severity, status, emailSent], () => {
|
||||
events.value = []
|
||||
hasMore.value = true
|
||||
loadFirstPage()
|
||||
})
|
||||
|
||||
watch(historyRange, () => {
|
||||
if (showDetail.value) loadHistory()
|
||||
})
|
||||
|
||||
function severityBadgeClass(severity: string | undefined): string {
|
||||
@@ -54,9 +338,19 @@ function statusBadgeClass(status: string | undefined): string {
|
||||
const s = String(status || '').trim().toLowerCase()
|
||||
if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30'
|
||||
if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30'
|
||||
if (s === 'manual_resolved') return 'bg-slate-50 text-slate-700 ring-slate-600/20 dark:bg-slate-900/30 dark:text-slate-300 dark:ring-slate-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string | undefined): string {
|
||||
const s = String(status || '').trim().toLowerCase()
|
||||
if (!s) return '-'
|
||||
if (s === 'firing') return t('admin.ops.alertEvents.status.firing')
|
||||
if (s === 'resolved') return t('admin.ops.alertEvents.status.resolved')
|
||||
if (s === 'manual_resolved') return t('admin.ops.alertEvents.status.manualResolved')
|
||||
return s.toUpperCase()
|
||||
}
|
||||
|
||||
const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</script>
|
||||
|
||||
@@ -69,11 +363,14 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="limit" :options="limitOptions" class="w-[88px]" @change="limit = Number($event || 100)" />
|
||||
<Select :model-value="timeRange" :options="timeRangeOptions" class="w-[120px]" @change="timeRange = String($event || '24h')" />
|
||||
<Select :model-value="severity" :options="severityOptions" class="w-[88px]" @change="severity = String($event || '')" />
|
||||
<Select :model-value="status" :options="statusOptions" class="w-[110px]" @change="status = String($event || '')" />
|
||||
<Select :model-value="emailSent" :options="emailSentOptions" class="w-[110px]" @change="emailSent = String($event || '')" />
|
||||
<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="load"
|
||||
@click="loadFirstPage"
|
||||
>
|
||||
<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" />
|
||||
@@ -96,7 +393,7 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[600px] overflow-y-auto">
|
||||
<div class="max-h-[600px] overflow-y-auto" @scroll="onScroll">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
@@ -104,16 +401,22 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
{{ t('admin.ops.alertEvents.table.time') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.status') }}
|
||||
{{ t('admin.ops.alertEvents.table.severity') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.severity') }}
|
||||
{{ t('admin.ops.alertEvents.table.platform') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.ruleId') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.title') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.metric') }}
|
||||
{{ t('admin.ops.alertEvents.table.duration') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.dimensions') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.email') }}
|
||||
@@ -121,45 +424,225 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="row in events" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<tr
|
||||
v-for="row in events"
|
||||
:key="row.id"
|
||||
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
@click="openDetail(row)"
|
||||
:title="row.title || ''"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDateTime(row.fired_at || row.created_at) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
|
||||
{{ String(row.status || '-').toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
|
||||
{{ row.severity || '-' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
|
||||
{{ formatStatusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
|
||||
{{ row.severity || '-' }}
|
||||
</span>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ getDimensionString(row, 'platform') || '-' }}
|
||||
</td>
|
||||
<td class="min-w-[280px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<div class="font-semibold">{{ row.title || '-' }}</div>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-mono">#{{ row.rule_id }}</span>
|
||||
</td>
|
||||
<td class="min-w-[260px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<div class="font-semibold truncate max-w-[360px]">{{ row.title || '-' }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span v-if="typeof row.metric_value === 'number' && typeof row.threshold_value === 'number'">
|
||||
{{ row.metric_value.toFixed(2) }} / {{ row.threshold_value.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
{{ formatDurationLabel(row) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ formatDimensionsSummary(row) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset"
|
||||
:class="row.email_sent ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' : 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'"
|
||||
class="inline-flex items-center justify-end gap-1.5"
|
||||
:title="row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored')"
|
||||
>
|
||||
{{ row.email_sent ? t('common.enabled') : t('common.disabled') }}
|
||||
<Icon
|
||||
v-if="row.email_sent"
|
||||
name="checkCircle"
|
||||
size="sm"
|
||||
class="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
name="ban"
|
||||
size="sm"
|
||||
class="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">
|
||||
{{ row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored') }}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loadingMore" class="flex items-center justify-center gap-2 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('admin.ops.alertEvents.loading') }}
|
||||
</div>
|
||||
<div v-else-if="!hasMore && events.length > 0" class="py-3 text-center text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="showDetail"
|
||||
:title="t('admin.ops.alertEvents.detail.title')"
|
||||
width="wide"
|
||||
:close-on-click-outside="true"
|
||||
@close="closeDetail"
|
||||
>
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-10 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="!selected" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(selected.severity || ''))">
|
||||
{{ selected.severity || '-' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(selected.status)">
|
||||
{{ formatStatusLabel(selected.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ selected.title || '-' }}
|
||||
</div>
|
||||
<div v-if="selected.description" class="mt-1 whitespace-pre-wrap text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ selected.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2 rounded-lg bg-white px-2 py-1 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<span class="text-[11px] font-bold text-gray-600 dark:text-gray-300">{{ t('admin.ops.alertEvents.detail.silence') }}</span>
|
||||
<Select
|
||||
:model-value="silenceDuration"
|
||||
:options="silenceDurationOptions"
|
||||
class="w-[110px]"
|
||||
@change="silenceDuration = String($event || '1h')"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="silenceAlert">
|
||||
<Icon name="ban" size="sm" />
|
||||
{{ t('common.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="detailActionLoading" @click="manualResolve">
|
||||
<Icon name="checkCircle" size="sm" />
|
||||
{{ t('admin.ops.alertEvents.detail.manualResolve') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.firedAt') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ formatDateTime(selected.fired_at || selected.created_at) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.resolvedAt') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ selected.resolved_at ? formatDateTime(selected.resolved_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.ruleId') }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<div class="font-mono text-sm font-bold text-gray-900 dark:text-white">#{{ selected.rule_id }}</div>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
|
||||
:href="`/admin/ops?open_alert_rules=1&alert_rule_id=${selected.rule_id}`"
|
||||
>
|
||||
<Icon name="externalLink" size="xs" />
|
||||
{{ t('admin.ops.alertEvents.detail.viewRule') }}
|
||||
</a>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700 dark:hover:bg-dark-700"
|
||||
:href="`/admin/ops?platform=${encodeURIComponent(getDimensionString(selected,'platform')||'')}&group_id=${selected.dimensions?.group_id || ''}&error_type=request&open_error_details=1`"
|
||||
>
|
||||
<Icon name="externalLink" size="xs" />
|
||||
{{ t('admin.ops.alertEvents.detail.viewLogs') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.alertEvents.detail.dimensions') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
<div v-if="getDimensionString(selected, 'platform')">platform={{ getDimensionString(selected, 'platform') }}</div>
|
||||
<div v-if="selected.dimensions?.group_id">group_id={{ selected.dimensions.group_id }}</div>
|
||||
<div v-if="getDimensionString(selected, 'region')">region={{ getDimensionString(selected, 'region') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.detail.historyTitle') }}</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.detail.historyHint') }}</div>
|
||||
</div>
|
||||
<Select :model-value="historyRange" :options="historyRangeOptions" class="w-[140px]" @change="historyRange = String($event || '7d')" />
|
||||
</div>
|
||||
|
||||
<div v-if="historyLoading" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.historyLoading') }}
|
||||
</div>
|
||||
<div v-else-if="history.length === 0" class="py-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.detail.historyEmpty') }}
|
||||
</div>
|
||||
<div v-else class="overflow-hidden rounded-lg border border-gray-100 dark:border-dark-700">
|
||||
<table class="min-w-full divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.time') }}</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.status') }}</th>
|
||||
<th class="px-3 py-2 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.table.metric') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-for="it in history" :key="it.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">{{ formatDateTime(it.fired_at || it.created_at) }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(it.status)">
|
||||
{{ formatStatusLabel(it.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span v-if="typeof it.metric_value === 'number' && typeof it.threshold_value === 'number'">
|
||||
{{ it.metric_value.toFixed(2) }} / {{ it.threshold_value.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,8 +12,46 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6 p-6">
|
||||
<!-- Top Summary -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<!-- Header actions -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="font-semibold text-gray-600 dark:text-gray-300">Resolved:</span>
|
||||
<span :class="(detail as any).resolved ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-300'">
|
||||
{{ (detail as any).resolved ? 'true' : 'false' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!(detail as any).resolved"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading"
|
||||
@click="markResolved(true)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.markResolved') || 'Mark resolved' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading"
|
||||
@click="markResolved(false)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.markUnresolved') || 'Mark unresolved' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex flex-wrap gap-2 border-b border-gray-200 pb-3 dark:border-dark-700">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='overview' ? 'opacity-100' : 'opacity-70'" @click="activeTab='overview'">{{ t('admin.ops.errorDetail.tabOverview') || 'Overview' }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='retries' ? 'opacity-100' : 'opacity-70'" @click="activeTab='retries'">{{ t('admin.ops.errorDetail.tabRetries') || 'Retries' }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='request' ? 'opacity-100' : 'opacity-70'" @click="activeTab='request'">{{ t('admin.ops.errorDetail.tabRequest') || 'Request' }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='response' ? 'opacity-100' : 'opacity-70'" @click="activeTab='response'">{{ t('admin.ops.errorDetail.tabResponse') || 'Response' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab==='overview'">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestId') }}</div>
|
||||
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -62,10 +100,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info -->
|
||||
<!-- Suggestion -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.basicInfo') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.suggestion') || 'Suggestion' }}</h3>
|
||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words">
|
||||
{{ handlingSuggestion }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classification -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.classification') || 'Classification' }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">phase</div>
|
||||
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">{{ detail.phase || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">owner</div>
|
||||
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">{{ (detail as any).error_owner || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">source</div>
|
||||
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">{{ (detail as any).error_source || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">retryable</div>
|
||||
<div class="mt-1 text-sm font-bold text-gray-900 dark:text-white">{{ (detail as any).is_retryable ? '✓' : '✗' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">resolved_at</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ (detail as any).resolved_at || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">resolved_by</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ (detail as any).resolved_by_user_id ?? '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">resolved_retry_id</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ (detail as any).resolved_retry_id ?? '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">retry_count</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ (detail as any).retry_count ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retry summary -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retrySummary') || 'Retry Summary' }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">total</div>
|
||||
<div class="mt-1 text-sm font-bold text-gray-900 dark:text-white">{{ retryHistory.length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">succeeded</div>
|
||||
<div class="mt-1 text-sm font-bold text-gray-900 dark:text-white">{{ retryHistory.filter(r => r.success === true).length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">failed</div>
|
||||
<div class="mt-1 text-sm font-bold text-gray-900 dark:text-white">{{ retryHistory.filter(r => r.success === false).length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">last</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ retryHistory[0]?.created_at || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.basicInfo') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.platform || '—' }}</div>
|
||||
@@ -132,30 +243,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retry -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetail.retryNote1') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="retrying" @click="openRetryConfirm('client')">
|
||||
{{ t('admin.ops.errorDetail.retryClient') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="retrying || !pinnedAccountId"
|
||||
@click="openRetryConfirm('upstream')"
|
||||
:title="pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.retryUpstream') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Retry -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetail.retryNote1') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-if="(detail as any).is_retryable">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="retrying" @click="openRetryConfirm('client')">
|
||||
{{ t('admin.ops.errorDetail.retryClient') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="retrying || !pinnedAccountId"
|
||||
@click="openRetryConfirm('upstream')"
|
||||
:title="pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.retryUpstream') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-xs font-semibold text-amber-700 dark:text-amber-300">{{ t('admin.ops.errorDetail.notRetryable') || 'Not retryable' }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="md:col-span-1">
|
||||
@@ -268,15 +385,98 @@
|
||||
><code>{{ prettyJSON(detail.request_body) }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Error body -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.errorBody') }}</h3>
|
||||
<pre
|
||||
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(detail.error_body) }}</code></pre>
|
||||
<!-- Error body -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.errorBody') }}</h3>
|
||||
<pre
|
||||
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(detail.error_body) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<div v-else-if="activeTab==='retries'">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.retryHistory') || 'Retry History' }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="loadRetryHistory">{{ t('common.refresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="retryHistoryLoading" class="text-sm text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="!retryHistory.length" class="text-sm text-gray-500 dark:text-gray-400">{{ t('common.noData') }}</div>
|
||||
<div v-else>
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.compareA') || 'Compare A' }}</div>
|
||||
<select v-model.number="compareA" class="input mt-2 w-full font-mono text-xs">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="a in retryHistory" :key="a.id" :value="a.id">#{{ a.id }} · {{ a.mode }} · {{ a.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.compareB') || 'Compare B' }}</div>
|
||||
<select v-model.number="compareB" class="input mt-2 w-full font-mono text-xs">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="b in retryHistory" :key="b.id" :value="b.id">#{{ b.id }} · {{ b.mode }} · {{ b.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedA || selectedB" class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="text-xs font-black text-gray-900 dark:text-white">{{ selectedA ? `#${selectedA.id} · ${selectedA.mode} · ${selectedA.status}` : '—' }}</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">http: <span class="font-mono">{{ selectedA?.http_status_code ?? '—' }}</span> · used: <span class="font-mono">{{ selectedA?.used_account_id ?? '—' }}</span></div>
|
||||
<pre class="mt-3 max-h-[320px] overflow-auto rounded-lg bg-gray-50 p-3 text-xs text-gray-800 dark:bg-dark-900 dark:text-gray-100"><code>{{ selectedA?.response_preview || '' }}</code></pre>
|
||||
<div v-if="selectedA?.error_message" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ selectedA.error_message }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="text-xs font-black text-gray-900 dark:text-white">{{ selectedB ? `#${selectedB.id} · ${selectedB.mode} · ${selectedB.status}` : '—' }}</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">http: <span class="font-mono">{{ selectedB?.http_status_code ?? '—' }}</span> · used: <span class="font-mono">{{ selectedB?.used_account_id ?? '—' }}</span></div>
|
||||
<pre class="mt-3 max-h-[320px] overflow-auto rounded-lg bg-gray-50 p-3 text-xs text-gray-800 dark:bg-dark-900 dark:text-gray-100"><code>{{ selectedB?.response_preview || '' }}</code></pre>
|
||||
<div v-if="selectedB?.error_message" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ selectedB.error_message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="a in retryHistory" :key="a.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-xs font-black text-gray-900 dark:text-white">#{{ a.id }} · {{ a.mode }} · {{ a.status }}</div>
|
||||
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">{{ a.created_at }}</div>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-4">
|
||||
<div><span class="text-gray-400">success:</span> <span class="font-mono">{{ a.success ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-400">http:</span> <span class="font-mono">{{ a.http_status_code ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-400">pinned:</span> <span class="font-mono">{{ a.pinned_account_id ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-400">used:</span> <span class="font-mono">{{ a.used_account_id ?? '—' }}</span></div>
|
||||
</div>
|
||||
<pre v-if="a.response_preview" class="mt-3 max-h-[240px] overflow-auto rounded-lg bg-gray-50 p-3 text-xs text-gray-800 dark:bg-dark-900 dark:text-gray-100"><code>{{ a.response_preview }}</code></pre>
|
||||
<div v-if="a.error_message" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ a.error_message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab==='request'">
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.requestBody') }}</h3>
|
||||
<pre class="mt-4 max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(detail.request_body) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab==='response'">
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') || 'Response' }}</h3>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ responseTabHint }}
|
||||
</div>
|
||||
<pre class="mt-4 max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(responseTabBody) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showRetryConfirm"
|
||||
@@ -285,6 +485,16 @@
|
||||
@confirm="runConfirmedRetry"
|
||||
@cancel="cancelRetry"
|
||||
/>
|
||||
|
||||
<div v-if="showRetryConfirm && !(detail as any)?.is_retryable" class="fixed inset-0 z-[60] flex items-end justify-center p-4 pointer-events-none">
|
||||
<div class="pointer-events-auto w-full max-w-xl rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="forceRetryAck" type="checkbox" class="h-4 w-4" />
|
||||
<span>{{ t('admin.ops.errorDetail.forceRetry') || 'I understand and want to force retry' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -293,7 +503,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsErrorDetail, type OpsRetryMode } from '@/api/admin/ops'
|
||||
import { opsAPI, type OpsErrorDetail, type OpsRetryMode, type OpsRetryAttempt } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { getSeverityClass } from '../utils/opsFormatters'
|
||||
|
||||
@@ -315,10 +525,20 @@ const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<OpsErrorDetail | null>(null)
|
||||
|
||||
const activeTab = ref<'overview' | 'retries' | 'request' | 'response'>('overview')
|
||||
|
||||
const retrying = ref(false)
|
||||
const showRetryConfirm = ref(false)
|
||||
const pendingRetryMode = ref<OpsRetryMode>('client')
|
||||
|
||||
const forceRetryAck = ref(false)
|
||||
const retryHistory = ref<OpsRetryAttempt[]>([])
|
||||
const retryHistoryLoading = ref(false)
|
||||
const showRetryHistory = ref(false)
|
||||
|
||||
const compareA = ref<number | null>(null)
|
||||
const compareB = ref<number | null>(null)
|
||||
|
||||
const pinnedAccountIdInput = ref('')
|
||||
const pinnedAccountId = computed<number | null>(() => {
|
||||
const raw = String(pinnedAccountIdInput.value || '').trim()
|
||||
@@ -369,6 +589,31 @@ function prettyJSON(raw?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const handlingSuggestion = computed(() => {
|
||||
const d: any = detail.value
|
||||
if (!d) return ''
|
||||
|
||||
const owner = String(d.error_owner || '').toLowerCase()
|
||||
const phase = String(d.phase || '').toLowerCase()
|
||||
|
||||
if (owner === 'provider' && phase === 'upstream') {
|
||||
if (retryHistory.value.some((r) => r.success === true) && d.resolved) {
|
||||
return t('admin.ops.errorDetail.suggestUpstreamResolved') || '✓ Upstream error resolved by retry; no action needed.'
|
||||
}
|
||||
return t('admin.ops.errorDetail.suggestUpstream') || 'Upstream instability: consider checking upstream account status, switching accounts, or retrying.'
|
||||
}
|
||||
if (owner === 'client' && phase === 'request') {
|
||||
return t('admin.ops.errorDetail.suggestRequest') || 'Client request validation error: contact customer to fix request parameters.'
|
||||
}
|
||||
if (owner === 'client' && phase === 'auth') {
|
||||
return t('admin.ops.errorDetail.suggestAuth') || 'Auth failed: verify API key/credentials.'
|
||||
}
|
||||
if (owner === 'platform') {
|
||||
return t('admin.ops.errorDetail.suggestPlatform') || 'Platform error: prioritize investigation and fix.'
|
||||
}
|
||||
return t('admin.ops.errorDetail.suggestGeneric') || 'See details for more context.'
|
||||
})
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -394,10 +639,17 @@ watch(
|
||||
([show, id]) => {
|
||||
if (!show) {
|
||||
detail.value = null
|
||||
retryHistory.value = []
|
||||
retryHistoryLoading.value = false
|
||||
showRetryHistory.value = false
|
||||
activeTab.value = 'overview'
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number' && id > 0) {
|
||||
fetchDetail(id)
|
||||
activeTab.value = 'overview'
|
||||
fetchDetail(id).then(() => {
|
||||
loadRetryHistory()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -405,11 +657,72 @@ watch(
|
||||
|
||||
function openRetryConfirm(mode: OpsRetryMode) {
|
||||
pendingRetryMode.value = mode
|
||||
// Force-ack required only when backend says not retryable.
|
||||
forceRetryAck.value = false
|
||||
showRetryConfirm.value = true
|
||||
}
|
||||
|
||||
async function loadRetryHistory() {
|
||||
if (!props.errorId) return
|
||||
retryHistoryLoading.value = true
|
||||
try {
|
||||
const items = await opsAPI.listRetryAttempts(props.errorId, 50)
|
||||
retryHistory.value = items || []
|
||||
|
||||
// Default compare selections: newest succeeded vs newest failed.
|
||||
if (retryHistory.value.length) {
|
||||
const succeeded = retryHistory.value.find((a) => a.success === true)
|
||||
const failed = retryHistory.value.find((a) => a.success === false)
|
||||
compareA.value = succeeded?.id ?? retryHistory.value[0].id
|
||||
compareB.value = failed?.id ?? (retryHistory.value[1]?.id ?? null)
|
||||
}
|
||||
} catch (err: any) {
|
||||
retryHistory.value = []
|
||||
compareA.value = null
|
||||
compareB.value = null
|
||||
appStore.showError(err?.message || 'Failed to load retry history')
|
||||
} finally {
|
||||
retryHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedA = computed(() => retryHistory.value.find((a) => a.id === compareA.value) || null)
|
||||
const selectedB = computed(() => retryHistory.value.find((a) => a.id === compareB.value) || null)
|
||||
|
||||
const bestSucceededAttempt = computed(() => retryHistory.value.find((a) => a.success === true) || null)
|
||||
|
||||
const responseTabBody = computed(() => {
|
||||
// Prefer any succeeded attempt preview; fall back to stored error body.
|
||||
const succeeded = bestSucceededAttempt.value
|
||||
if (succeeded?.response_preview) return succeeded.response_preview
|
||||
return detail.value?.error_body || ''
|
||||
})
|
||||
|
||||
const responseTabHint = computed(() => {
|
||||
const succeeded = bestSucceededAttempt.value
|
||||
if (succeeded?.response_preview) {
|
||||
return t('admin.ops.errorDetail.responseHintSucceeded', { id: String(succeeded.id) }) || `Showing succeeded retry response_preview (#${succeeded.id})`
|
||||
}
|
||||
return t('admin.ops.errorDetail.responseHintFallback') || 'No succeeded retry found; showing stored error_body'
|
||||
})
|
||||
|
||||
async function markResolved(resolved: boolean) {
|
||||
if (!props.errorId) return
|
||||
try {
|
||||
await opsAPI.updateErrorResolved(props.errorId, resolved)
|
||||
await fetchDetail(props.errorId)
|
||||
appStore.showSuccess(resolved ? (t('admin.ops.errorDetails.resolved') || 'Resolved') : (t('admin.ops.errorDetails.unresolved') || 'Unresolved'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || 'Failed to update resolved status')
|
||||
}
|
||||
}
|
||||
|
||||
const retryConfirmMessage = computed(() => {
|
||||
const mode = pendingRetryMode.value
|
||||
const retryable = !!(detail.value as any)?.is_retryable
|
||||
if (!retryable) {
|
||||
return t('admin.ops.errorDetail.forceRetryHint') || 'This error is not recommended to retry. Check the box to force retry.'
|
||||
}
|
||||
if (mode === 'upstream') {
|
||||
return t('admin.ops.errorDetail.confirmRetryMessage')
|
||||
}
|
||||
@@ -432,18 +745,28 @@ const statusClass = computed(() => {
|
||||
async function runConfirmedRetry() {
|
||||
if (!props.errorId) return
|
||||
const mode = pendingRetryMode.value
|
||||
const retryable = !!(detail.value as any)?.is_retryable
|
||||
if (!retryable && !forceRetryAck.value) {
|
||||
appStore.showError(t('admin.ops.errorDetail.forceRetryNeedAck') || 'Please confirm you want to force retry')
|
||||
return
|
||||
}
|
||||
|
||||
showRetryConfirm.value = false
|
||||
|
||||
retrying.value = true
|
||||
try {
|
||||
const req =
|
||||
mode === 'upstream'
|
||||
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined }
|
||||
: { mode }
|
||||
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined, force: !retryable ? true : undefined }
|
||||
: { mode, force: !retryable ? true : undefined }
|
||||
|
||||
const res = await opsAPI.retryErrorRequest(props.errorId, req)
|
||||
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
|
||||
appStore.showSuccess(summary)
|
||||
|
||||
// Refresh detail + history so resolved reflects auto resolution
|
||||
await fetchDetail(props.errorId)
|
||||
await loadRetryHistory()
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.ops.retryFailed'))
|
||||
} finally {
|
||||
|
||||
@@ -31,6 +31,8 @@ const pageSize = ref(20)
|
||||
const q = ref('')
|
||||
const statusCode = ref<number | null>(null)
|
||||
const phase = ref<string>('')
|
||||
const errorOwner = ref<string>('')
|
||||
const resolvedStatus = ref<string>('unresolved')
|
||||
const accountIdInput = ref<string>('')
|
||||
|
||||
const accountId = computed<number | null>(() => {
|
||||
@@ -52,15 +54,31 @@ const statusCodeSelectOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const ownerSelectOptions = computed(() => {
|
||||
return [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'provider', label: 'provider' },
|
||||
{ value: 'client', label: 'client' },
|
||||
{ value: 'platform', label: 'platform' }
|
||||
]
|
||||
})
|
||||
|
||||
const resolvedSelectOptions = computed(() => {
|
||||
return [
|
||||
{ value: 'unresolved', label: t('admin.ops.errorDetails.unresolved') || 'unresolved' },
|
||||
{ value: 'all', label: t('common.all') },
|
||||
{ value: 'resolved', label: t('admin.ops.errorDetails.resolved') || 'resolved' }
|
||||
]
|
||||
})
|
||||
|
||||
const phaseSelectOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'request', label: 'request' },
|
||||
{ value: 'auth', label: 'auth' },
|
||||
{ value: 'routing', label: 'routing' },
|
||||
{ value: 'upstream', label: 'upstream' },
|
||||
{ value: 'network', label: 'network' },
|
||||
{ value: 'routing', label: 'routing' },
|
||||
{ value: 'auth', label: 'auth' },
|
||||
{ value: 'billing', label: 'billing' },
|
||||
{ value: 'concurrency', label: 'concurrency' },
|
||||
{ value: 'internal', label: 'internal' }
|
||||
]
|
||||
return options
|
||||
@@ -92,6 +110,14 @@ async function fetchErrorLogs() {
|
||||
const phaseVal = String(phase.value || '').trim()
|
||||
if (phaseVal) params.phase = phaseVal
|
||||
|
||||
const ownerVal = String(errorOwner.value || '').trim()
|
||||
if (ownerVal) params.error_owner = ownerVal
|
||||
|
||||
const resolvedVal = String(resolvedStatus.value || '').trim()
|
||||
if (resolvedVal === 'resolved') params.resolved = 'true'
|
||||
else if (resolvedVal === 'unresolved') params.resolved = 'false'
|
||||
// 'all' -> omit
|
||||
|
||||
const res = await opsAPI.listErrorLogs(params)
|
||||
rows.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
@@ -108,6 +134,8 @@ function resetFilters() {
|
||||
q.value = ''
|
||||
statusCode.value = null
|
||||
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
|
||||
errorOwner.value = ''
|
||||
resolvedStatus.value = 'unresolved'
|
||||
accountIdInput.value = ''
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
@@ -154,7 +182,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [statusCode.value, phase.value] as const,
|
||||
() => [statusCode.value, phase.value, errorOwner.value, resolvedStatus.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
@@ -177,8 +205,8 @@ watch(
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div class="lg:col-span-5">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-14">
|
||||
<div class="lg:col-span-4">
|
||||
<div class="relative group">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<svg
|
||||
@@ -208,6 +236,14 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<Select :model-value="errorOwner" :options="ownerSelectOptions" class="w-full" @update:model-value="errorOwner = String($event ?? '')" />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<Select :model-value="resolvedStatus" :options="resolvedSelectOptions" class="w-full" @update:model-value="resolvedStatus = String($event ?? 'unresolved')" />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1">
|
||||
<input
|
||||
v-model="accountIdInput"
|
||||
type="text"
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
>
|
||||
{{ t('admin.ops.errorLog.timeId') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.type') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
@@ -49,7 +55,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
|
||||
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
<td colspan="7" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.ops.errorLog.noErrors') }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -79,6 +85,30 @@
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Type -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
|
||||
getTypeBadge(log).className
|
||||
]"
|
||||
>
|
||||
{{ getTypeBadge(log).label }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<div v-if="(log as any).error_owner" class="flex items-center gap-1">
|
||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ (log as any).error_owner }}</span>
|
||||
</div>
|
||||
<div v-if="(log as any).error_source" class="flex items-center gap-1">
|
||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ (log as any).error_source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Context (Platform/Model) -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
@@ -182,6 +212,37 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
const owner = String((log as any).error_owner || '').toLowerCase()
|
||||
|
||||
// Mapping aligned with the design:
|
||||
// - upstream/provider => 🔴 上游
|
||||
// - request/client => 🟡 请求
|
||||
// - auth/client => 🔵 认证
|
||||
// - routing/platform => 🟣 路由
|
||||
// - internal/platform => ⚫ 内部
|
||||
if (phase === 'upstream' && owner === 'provider') {
|
||||
return { label: '🔴 上游', className: 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30' }
|
||||
}
|
||||
if (phase === 'request' && owner === 'client') {
|
||||
return { label: '🟡 请求', className: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30' }
|
||||
}
|
||||
if (phase === 'auth' && owner === 'client') {
|
||||
return { label: '🔵 认证', className: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-500/30' }
|
||||
}
|
||||
if (phase === 'routing' && owner === 'platform') {
|
||||
return { label: '🟣 路由', className: 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30' }
|
||||
}
|
||||
if (phase === 'internal' && owner === 'platform') {
|
||||
return { label: '⚫ 内部', className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
|
||||
}
|
||||
|
||||
// Fallback: show phase/owner for unknown combos.
|
||||
const fallback = phase || owner || 'unknown'
|
||||
return { label: fallback, className: 'bg-gray-50 text-gray-700 ring-gray-600/10 dark:bg-dark-900 dark:text-gray-300 dark:ring-dark-700' }
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
|
||||
Reference in New Issue
Block a user