fix(ops): 优化错误日志查询和详情展示
- 新增 GetErrorLogByID 接口用于获取单个错误日志详情 - 优化 GetErrorLogs 过滤逻辑,简化参数处理 - 简化前端错误详情模态框代码,提升可维护性 - 更新相关 API 接口和 i18n 翻译
This commit is contained in:
@@ -400,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
|
||||
|
||||
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
|
||||
if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title
|
||||
// Ensure only one modal visible at a time.
|
||||
showErrorDetails.value = false
|
||||
showErrorModal.value = false
|
||||
showRequestDetails.value = true
|
||||
}
|
||||
|
||||
function openErrorDetails(kind: 'request' | 'upstream') {
|
||||
errorDetailsType.value = kind
|
||||
// Ensure only one modal visible at a time.
|
||||
showRequestDetails.value = false
|
||||
showErrorModal.value = false
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
|
||||
@@ -446,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) {
|
||||
|
||||
function openError(id: number) {
|
||||
selectedErrorId.value = id
|
||||
// Ensure only one modal visible at a time.
|
||||
showErrorDetails.value = false
|
||||
showRequestDetails.value = false
|
||||
showErrorModal.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,526 +12,154 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6 p-6">
|
||||
<!-- 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">{{ t('admin.ops.errorDetail.resolution') }}</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 ? t('admin.ops.errorDetails.resolved') : t('admin.ops.errorDetails.unresolved') }}
|
||||
</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') }}
|
||||
</button>
|
||||
<button v-else type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="markResolved(false)">
|
||||
{{ t('admin.ops.errorDetail.markUnresolved') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex flex-wrap items-center 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') }}
|
||||
</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') }}
|
||||
</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') }}
|
||||
</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') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasUpstreamErrorContent"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:class="activeTab === 'upstreamErrors' ? 'opacity-100' : 'opacity-70'"
|
||||
@click="activeTab = 'upstreamErrors'"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.upstreamErrors') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<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">
|
||||
{{ detail.request_id || detail.client_request_id || '—' }}
|
||||
</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.errorDetail.time') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDateTime(detail.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.errorDetail.phase') }}</div>
|
||||
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">
|
||||
{{ detail.phase || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ detail.type || '—' }}
|
||||
</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.errorDetail.status') }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
|
||||
{{ detail.status_code }}
|
||||
</span>
|
||||
<span v-if="detail.severity" :class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', severityClass]">
|
||||
{{ detail.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg: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">
|
||||
{{ requestId || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message + retry (right aligned) -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.errorDetail.message') }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap justify-end 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
|
||||
v-if="props.errorType === 'upstream'"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="retrying"
|
||||
@click="openRetryConfirm('upstream')"
|
||||
>
|
||||
{{ 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') }}</span>
|
||||
</template>
|
||||
</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.errorDetail.time') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDateTime(detail.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 break-words text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<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.account') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.account_name || (detail.account_id != null ? String(detail.account_id) : '—') }}
|
||||
</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.errorDetail.platform') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.platform || '—' }}
|
||||
</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.errorDetail.group') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.group_name || (detail.group_id != null ? String(detail.group_id) : '—') }}
|
||||
</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.errorDetail.model') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.model || '—' }}
|
||||
</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.errorDetail.status') }}</div>
|
||||
<div class="mt-1">
|
||||
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
|
||||
{{ detail.status_code }}
|
||||
</span>
|
||||
</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.errorDetail.message') }}</div>
|
||||
<div class="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white" :title="detail.message">
|
||||
{{ detail.message || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags (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') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700">
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.phase') }}:
|
||||
<span class="ml-1 font-mono">{{ phaseLabel }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700">
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.owner') }}:
|
||||
<span class="ml-1 font-mono">{{ ownerLabel }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700">
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.source') }}:
|
||||
<span class="ml-1 font-mono">{{ sourceLabel }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700">
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.retryable') }}:
|
||||
<span class="ml-1 font-mono">{{ (detail as any).is_retryable ? t('common.yes') : t('common.no') }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="(detail as any).resolved_at"
|
||||
class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.resolvedAt') }}: <span class="ml-1 font-mono">{{ (detail as any).resolved_at }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="(detail as any).resolved_by_user_id != null"
|
||||
class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.resolvedBy') }}:
|
||||
<span class="ml-1 font-mono">{{ (detail as any).resolved_by_user_id }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="(detail as any).resolved_retry_id != null"
|
||||
class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.resolvedRetryId') }}:
|
||||
<span class="ml-1 font-mono">{{ (detail as any).resolved_retry_id }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="(detail as any).retry_count != null"
|
||||
class="inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-700 ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-200 dark:ring-dark-700"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.classificationKeys.retryCount') }}: <span class="ml-1 font-mono">{{ (detail as any).retry_count }}</span>
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.model || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.group') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<el-tooltip v-if="detail.group_id" :content="t('admin.ops.errorLog.id') + ' ' + detail.group_id" placement="top">
|
||||
<span>{{ detail.group_name || detail.group_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.account') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<el-tooltip v-if="detail.account_id" :content="t('admin.ops.errorLog.id') + ' ' + detail.account_id" placement="top">
|
||||
<span>{{ detail.account_name || detail.account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">TTFT</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.time_to_first_token_ms != null ? `${detail.time_to_first_token_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.businessLimited') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.is_business_limited ? 'true' : 'false' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.requestPath') }}</div>
|
||||
<div class="mt-1 break-all font-mono text-xs text-gray-700 dark:text-gray-200">
|
||||
{{ detail.request_path || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timings -->
|
||||
<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.timings') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.auth') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.auth_latency_ms != null ? `${detail.auth_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.routing') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.routing_latency_ms != null ? `${detail.routing_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstream') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_latency_ms != null ? `${detail.upstream_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.response') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.response_latency_ms != null ? `${detail.response_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.errorType === 'upstream'" class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label>
|
||||
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" disabled />
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.pinnedToOriginalAccountId') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upstream Errors Tab -->
|
||||
<div v-else-if="activeTab === 'upstreamErrors'" class="space-y-6">
|
||||
<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.errorDetails.upstreamErrors') }}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.status') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_status_code != null ? detail.upstream_status_code : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.message') }}</div>
|
||||
<div class="mt-1 break-words text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_error_message || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.upstream_error_detail" class="mt-4">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.detail') }}</div>
|
||||
<pre
|
||||
class="mt-2 max-h-[240px] 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.upstream_error_detail) }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.upstream_errors" class="mt-5">
|
||||
<div class="mb-2 text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.upstreamErrors') }}</div>
|
||||
|
||||
<div v-if="upstreamErrors.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(ev, idx) in upstreamErrors"
|
||||
:key="idx"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm 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-800 dark:text-gray-100">#{{ idx + 1 }} <span v-if="ev.kind" class="font-mono">{{ ev.kind }}</span></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="props.errorType !== 'upstream'"
|
||||
type="button"
|
||||
class="rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-700 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
:disabled="retrying || !ev.upstream_request_body"
|
||||
:title="ev.upstream_request_body ? '' : t('admin.ops.errorDetail.missingUpstreamRequestBody')"
|
||||
@click.stop="retryUpstreamEvent(idx)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.retryUpstream') }} #{{ idx + 1 }}
|
||||
</button>
|
||||
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ ev.at_unix_ms ? formatDateTime(new Date(ev.at_unix_ms)) : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.account') }}:</span>
|
||||
<el-tooltip v-if="ev.account_id" :content="t('admin.ops.errorLog.id') + ' ' + ev.account_id" placement="top">
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ ev.account_name || ev.account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="ml-1">—</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.upstream_status_code ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="sm:col-span-2 break-all">
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.upstream_request_id || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.message" class="mt-2 break-words text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ ev.message }}
|
||||
</div>
|
||||
|
||||
<pre
|
||||
v-if="ev.detail"
|
||||
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(ev.detail) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
v-else
|
||||
class="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.upstream_errors) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
|
||||
<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') }}</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(primaryResponseBody || '') }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Retries -->
|
||||
<div v-else-if="activeTab === 'retries'">
|
||||
<!-- Upstream errors list (only for request errors) -->
|
||||
<div v-if="showUpstreamList" class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<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') }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="loadRetryHistory">{{ t('common.refresh') }}</button>
|
||||
</div>
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetails.upstreamErrors') }}</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" v-if="correlatedUpstreamLoading">{{ t('common.loading') }}</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') }}</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') }}</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="!correlatedUpstreamLoading && !correlatedUpstreamErrors.length" class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.noData') }}
|
||||
</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> · {{ t('admin.ops.errorDetail.retryMeta.used') }}:
|
||||
<span class="font-mono">
|
||||
<el-tooltip v-if="selectedA?.used_account_id" :content="'ID: ' + selectedA.used_account_id" placement="top">
|
||||
<span class="font-medium">{{ selectedA.used_account_name || selectedA.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="(ev, idx) in correlatedUpstreamErrors"
|
||||
:key="ev.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">
|
||||
#{{ idx + 1 }}
|
||||
<span v-if="ev.type" class="ml-2 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-[10px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">{{ ev.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ ev.status_code ?? '—' }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[10px] font-bold text-primary-700 hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-primary-200 dark:hover:bg-dark-700"
|
||||
:disabled="!getUpstreamResponsePreview(ev)"
|
||||
:title="getUpstreamResponsePreview(ev) ? '' : t('common.noData')"
|
||||
@click="toggleUpstreamDetail(ev.id)"
|
||||
>
|
||||
<Icon
|
||||
:name="expandedUpstreamDetailIds.has(ev.id) ? 'chevronDown' : 'chevronRight'"
|
||||
size="xs"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
expandedUpstreamDetailIds.has(ev.id)
|
||||
? t('admin.ops.errorDetail.responsePreview.collapse')
|
||||
: t('admin.ops.errorDetail.responsePreview.expand')
|
||||
}}
|
||||
</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> · {{ t('admin.ops.errorDetail.retryMeta.used') }}:
|
||||
<span class="font-mono">
|
||||
<el-tooltip v-if="selectedB?.used_account_id" :content="'ID: ' + selectedB.used_account_id" placement="top">
|
||||
<span class="font-medium">{{ selectedB.used_account_name || selectedB.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</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>
|
||||
</button>
|
||||
</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">{{ t('admin.ops.errorDetail.retryMeta.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">{{ t('admin.ops.errorDetail.retryMeta.pinned') }}:</span>
|
||||
<el-tooltip v-if="a.pinned_account_id" :content="'ID: ' + a.pinned_account_id" placement="top">
|
||||
<span class="font-mono ml-1">{{ a.pinned_account_name || a.pinned_account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="font-mono ml-1">—</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.retryMeta.used') }}:</span>
|
||||
<el-tooltip v-if="a.used_account_id" :content="'ID: ' + a.used_account_id" placement="top">
|
||||
<span class="font-mono ml-1">{{ a.used_account_name || a.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="font-mono ml-1">—</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 class="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.status_code ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span>
|
||||
<span class="ml-1 font-mono">{{ ev.request_id || ev.client_request_id || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request tab -->
|
||||
<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-if="ev.message" class="mt-3 break-words text-sm font-medium text-gray-900 dark:text-white">{{ ev.message }}</div>
|
||||
|
||||
<!-- Response tab -->
|
||||
<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') }}</h3>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ responseTabHint }}
|
||||
<pre
|
||||
v-if="expandedUpstreamDetailIds.has(ev.id)"
|
||||
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(getUpstreamResponsePreview(ev)) }}</code></pre>
|
||||
</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"
|
||||
:title="t('admin.ops.errorDetail.confirmRetry')"
|
||||
:message="retryConfirmMessage"
|
||||
@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') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsErrorDetail, type OpsRetryAttempt } from '@/api/admin/ops'
|
||||
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { getSeverityClass } from '../utils/opsFormatters'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -552,55 +180,20 @@ const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<OpsErrorDetail | null>(null)
|
||||
|
||||
const activeTab = ref<'overview' | 'retries' | 'request' | 'response' | 'upstreamErrors'>('overview')
|
||||
const showUpstreamList = computed(() => props.errorType === 'request')
|
||||
|
||||
const hasUpstreamErrorContent = computed(() => {
|
||||
const d = detail.value as any
|
||||
return !!(d?.upstream_status_code || d?.upstream_error_message || d?.upstream_error_detail || d?.upstream_errors)
|
||||
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
|
||||
|
||||
const primaryResponseBody = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
if (props.errorType === 'upstream') {
|
||||
return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
|
||||
}
|
||||
return detail.value.error_body || ''
|
||||
})
|
||||
|
||||
function normalizeEnum(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
const phaseLabel = computed(() => {
|
||||
const phase = normalizeEnum(detail.value?.phase)
|
||||
if (!phase) return '—'
|
||||
const key = `admin.ops.errorDetails.phase.${phase}`
|
||||
const translated = t(key)
|
||||
return translated === key ? phase : translated
|
||||
})
|
||||
|
||||
const ownerLabel = computed(() => {
|
||||
const owner = normalizeEnum((detail.value as any)?.error_owner)
|
||||
if (!owner) return '—'
|
||||
const key = `admin.ops.errorDetails.owner.${owner}`
|
||||
const translated = t(key)
|
||||
return translated === key ? owner : translated
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
const source = normalizeEnum((detail.value as any)?.error_source)
|
||||
if (!source) return '—'
|
||||
const key = `admin.ops.errorDetail.source.${source}`
|
||||
const translated = t(key)
|
||||
return translated === key ? source : translated
|
||||
})
|
||||
|
||||
const retrying = ref(false)
|
||||
const showRetryConfirm = ref(false)
|
||||
const pendingRetryMode = ref<'client' | 'upstream' | 'upstream_event'>('client')
|
||||
|
||||
const forceRetryAck = ref(false)
|
||||
const retryHistory = ref<OpsRetryAttempt[]>([])
|
||||
const retryHistoryLoading = ref(false)
|
||||
|
||||
const compareA = ref<number | null>(null)
|
||||
const compareB = ref<number | null>(null)
|
||||
|
||||
const pinnedAccountIdInput = ref('')
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.errorId) return t('admin.ops.errorDetail.title')
|
||||
@@ -609,29 +202,40 @@ const title = computed(() => {
|
||||
|
||||
const emptyText = computed(() => t('admin.ops.errorDetail.noErrorSelected'))
|
||||
|
||||
type UpstreamErrorEvent = {
|
||||
at_unix_ms?: number
|
||||
platform?: string
|
||||
account_id?: number
|
||||
account_name?: string
|
||||
upstream_status_code?: number
|
||||
upstream_request_id?: string
|
||||
upstream_request_body?: string
|
||||
kind?: string
|
||||
message?: string
|
||||
detail?: string
|
||||
const correlatedUpstream = ref<OpsErrorDetail[]>([])
|
||||
const correlatedUpstreamLoading = ref(false)
|
||||
|
||||
const correlatedUpstreamErrors = computed<OpsErrorDetail[]>(() => correlatedUpstream.value)
|
||||
|
||||
const expandedUpstreamDetailIds = ref(new Set<number>())
|
||||
|
||||
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
|
||||
return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim()
|
||||
}
|
||||
|
||||
const upstreamErrors = computed<UpstreamErrorEvent[]>(() => {
|
||||
const raw = detail.value?.upstream_errors
|
||||
if (!raw) return []
|
||||
function toggleUpstreamDetail(id: number) {
|
||||
const next = new Set(expandedUpstreamDetailIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedUpstreamDetailIds.value = next
|
||||
}
|
||||
|
||||
async function fetchCorrelatedUpstreamErrors(requestErrorId: number) {
|
||||
correlatedUpstreamLoading.value = true
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as UpstreamErrorEvent[]) : []
|
||||
} catch {
|
||||
return []
|
||||
const res = await opsAPI.listRequestErrorUpstreamErrors(
|
||||
requestErrorId,
|
||||
{ page: 1, page_size: 100, view: 'all' },
|
||||
{ include_detail: true }
|
||||
)
|
||||
correlatedUpstream.value = res.items || []
|
||||
} catch (err) {
|
||||
console.error('[OpsErrorDetailModal] Failed to load correlated upstream errors', err)
|
||||
correlatedUpstream.value = []
|
||||
} finally {
|
||||
correlatedUpstreamLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
@@ -652,9 +256,6 @@ async function fetchDetail(id: number) {
|
||||
const kind = props.errorType || (detail.value?.phase === 'upstream' ? 'upstream' : 'request')
|
||||
const d = kind === 'upstream' ? await opsAPI.getUpstreamErrorDetail(id) : await opsAPI.getRequestErrorDetail(id)
|
||||
detail.value = d
|
||||
|
||||
// Keep showing original account_id (read-only hint for upstream retries).
|
||||
pinnedAccountIdInput.value = d.account_id && d.account_id > 0 ? String(d.account_id) : ''
|
||||
} catch (err: any) {
|
||||
detail.value = null
|
||||
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail'))
|
||||
@@ -668,105 +269,21 @@ watch(
|
||||
([show, id]) => {
|
||||
if (!show) {
|
||||
detail.value = null
|
||||
retryHistory.value = []
|
||||
retryHistoryLoading.value = false
|
||||
activeTab.value = 'overview'
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number' && id > 0) {
|
||||
activeTab.value = 'overview'
|
||||
fetchDetail(id).then(() => {
|
||||
loadRetryHistory()
|
||||
})
|
||||
expandedUpstreamDetailIds.value = new Set()
|
||||
fetchDetail(id)
|
||||
if (props.errorType === 'request') {
|
||||
fetchCorrelatedUpstreamErrors(id)
|
||||
} else {
|
||||
correlatedUpstream.value = []
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function openRetryConfirm(mode: 'client' | 'upstream' | 'upstream_event') {
|
||||
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 || t('admin.ops.errorDetail.failedToLoadRetryHistory'))
|
||||
} 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) })
|
||||
}
|
||||
return t('admin.ops.errorDetail.responseHintFallback')
|
||||
})
|
||||
|
||||
async function markResolved(resolved: boolean) {
|
||||
if (!props.errorId) return
|
||||
try {
|
||||
const kind = props.errorType || (detail.value?.phase === 'upstream' ? 'upstream' : 'request')
|
||||
if (kind === 'upstream') {
|
||||
await opsAPI.updateUpstreamErrorResolved(props.errorId, resolved)
|
||||
} else {
|
||||
await opsAPI.updateRequestErrorResolved(props.errorId, resolved)
|
||||
}
|
||||
await fetchDetail(props.errorId)
|
||||
appStore.showSuccess(resolved ? t('admin.ops.errorDetails.resolved') : t('admin.ops.errorDetails.unresolved'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.ops.errorDetail.failedToUpdateResolvedStatus'))
|
||||
}
|
||||
}
|
||||
|
||||
const retryConfirmMessage = computed(() => {
|
||||
const mode = pendingRetryMode.value
|
||||
const retryable = !!(detail.value as any)?.is_retryable
|
||||
if (!retryable) {
|
||||
return t('admin.ops.errorDetail.forceRetryHint')
|
||||
}
|
||||
if (mode === 'upstream') {
|
||||
return t('admin.ops.errorDetail.confirmRetryMessage')
|
||||
}
|
||||
return t('admin.ops.errorDetail.confirmRetryHint')
|
||||
})
|
||||
|
||||
const severityClass = computed(() => {
|
||||
if (!detail.value?.severity) return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
return getSeverityClass(detail.value.severity)
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const code = detail.value?.status_code ?? 0
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
@@ -775,63 +292,4 @@ const statusClass = computed(() => {
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
})
|
||||
|
||||
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'))
|
||||
return
|
||||
}
|
||||
|
||||
showRetryConfirm.value = false
|
||||
|
||||
retrying.value = true
|
||||
try {
|
||||
const kind = props.errorType || (detail.value?.phase === 'upstream' ? 'upstream' : 'request')
|
||||
|
||||
let res
|
||||
if (kind === 'upstream') {
|
||||
// Upstream error retries always pin the original account_id.
|
||||
res = await opsAPI.retryUpstreamError(props.errorId)
|
||||
} else {
|
||||
if (mode === 'client') {
|
||||
res = await opsAPI.retryRequestErrorClient(props.errorId)
|
||||
} else {
|
||||
throw new Error(t('admin.ops.errorDetail.unsupportedRetryMode'))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryUpstreamEvent(idx: number) {
|
||||
if (!props.errorId) return
|
||||
try {
|
||||
retrying.value = true
|
||||
const res = await opsAPI.retryRequestErrorUpstreamEvent(props.errorId, idx)
|
||||
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
|
||||
appStore.showSuccess(summary)
|
||||
await fetchDetail(props.errorId)
|
||||
await loadRetryHistory()
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.ops.retryFailed'))
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRetry() {
|
||||
showRetryConfirm.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import OpsErrorLogTable from './OpsErrorLogTable.vue'
|
||||
@@ -22,23 +21,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const retryingUpstream = ref<number | null>(null)
|
||||
async function retryUpstreamError(id: number) {
|
||||
try {
|
||||
retryingUpstream.value = id
|
||||
const res = await opsAPI.retryUpstreamError(id)
|
||||
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
|
||||
appStore.showSuccess(summary)
|
||||
page.value = 1
|
||||
await fetchErrorLogs()
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.ops.retryFailed'))
|
||||
} finally {
|
||||
retryingUpstream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<OpsErrorLog[]>([])
|
||||
@@ -50,7 +33,6 @@ const q = ref('')
|
||||
const statusCode = ref<number | 'other' | null>(null)
|
||||
const phase = ref<string>('')
|
||||
const errorOwner = ref<string>('')
|
||||
const resolvedStatus = ref<string>('unresolved')
|
||||
const viewMode = ref<'errors' | 'excluded' | 'all'>('errors')
|
||||
|
||||
|
||||
@@ -76,13 +58,6 @@ const ownerSelectOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
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 viewModeSelectOptions = computed(() => {
|
||||
return [
|
||||
@@ -135,10 +110,6 @@ async function fetchErrorLogs() {
|
||||
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 = props.errorType === 'upstream'
|
||||
? await opsAPI.listUpstreamErrors(params)
|
||||
@@ -159,7 +130,6 @@ async function fetchErrorLogs() {
|
||||
statusCode.value = null
|
||||
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
|
||||
errorOwner.value = ''
|
||||
resolvedStatus.value = 'unresolved'
|
||||
viewMode.value = 'errors'
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
@@ -207,7 +177,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [statusCode.value, phase.value, errorOwner.value, resolvedStatus.value, viewMode.value] as const,
|
||||
() => [statusCode.value, phase.value, errorOwner.value, viewMode.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
@@ -255,9 +225,7 @@ watch(
|
||||
<Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" />
|
||||
</div>
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="resolvedStatus" :options="resolvedSelectOptions" @update:model-value="resolvedStatus = String($event ?? 'unresolved')" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="compact-select">
|
||||
<Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" />
|
||||
@@ -285,7 +253,7 @@ watch(
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@openErrorDetail="emit('openErrorDetail', $event)"
|
||||
@retryUpstream="retryUpstreamError"
|
||||
|
||||
@update:page="page = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user