Commit 90bce60b authored by yangjianbo's avatar yangjianbo
Browse files

feat: merge dev

parent a458e684
...@@ -12,12 +12,12 @@ ...@@ -12,12 +12,12 @@
</div> </div>
<div v-else class="space-y-6 p-6"> <div v-else class="space-y-6 p-6">
<!-- Top Summary --> <!-- Summary -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4"> <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="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="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"> <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 || '' }} {{ requestId || '' }}
</div> </div>
</div> </div>
...@@ -29,277 +29,149 @@ ...@@ -29,277 +29,149 @@
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900"> <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="text-xs font-bold uppercase tracking-wider text-gray-400">
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white"> {{ isUpstreamError(detail) ? t('admin.ops.errorDetail.account') : t('admin.ops.errorDetail.user') }}
{{ detail.phase || '' }}
</div> </div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.type || '' }} <template v-if="isUpstreamError(detail)">
{{ detail.account_name || (detail.account_id != null ? String(detail.account_id) : '') }}
</template>
<template v-else>
{{ detail.user_email || (detail.user_id != null ? String(detail.user_id) : '') }}
</template>
</div> </div>
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900"> <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="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2"> <div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]"> {{ detail.platform || '—' }}
{{ 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> </div>
</div> </div>
</div>
<!-- Message --> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"> <div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.group') }}</div>
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.message') }}</h3> <div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words"> {{ detail.group_name || (detail.group_id != null ? String(detail.group_id) : '—') }}
{{ detail.message || '' }}
</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.latency') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.latency_ms != null ? `${detail.latency_ms}ms` : '' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.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>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.requestPath') }}</div>
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200 break-all">
{{ detail.request_path || '' }}
</div>
</div> </div>
</div> </div>
</div>
<!-- Timings (best-effort fields) --> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"> <div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
<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="mt-1 text-sm font-medium text-gray-900 dark:text-white">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> {{ detail.model || '—' }}
<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>
</div>
<!-- Retry --> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"> <div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.status') }}</div>
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start"> <div class="mt-1">
<div class="space-y-1"> <span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3> {{ detail.status_code }}
<div class="text-xs text-gray-500 dark:text-gray-400"> </span>
{{ 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>
</div> </div>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="md:col-span-1"> <div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.message') }}</div>
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label> <div class="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white" :title="detail.message">
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" :placeholder="t('admin.ops.errorDetail.pinnedAccountIdHint')" /> {{ detail.message || '—' }}
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetail.retryNote2') }}
</div>
</div>
<div class="md:col-span-2">
<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.retryNotes') }}</div>
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs text-gray-600 dark:text-gray-300">
<li>{{ t('admin.ops.errorDetail.retryNote3') }}</li>
<li>{{ t('admin.ops.errorDetail.retryNote4') }}</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Upstream errors --> <!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
<div <div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
v-if="detail.upstream_status_code || detail.upstream_error_message || detail.upstream_error_detail || detail.upstream_errors" <h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') }}</h3>
class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900" <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>
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">
{{ t('admin.ops.errorDetails.upstreamErrors') }} <!-- Upstream errors list (only for request errors) -->
</h3> <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="grid grid-cols-1 gap-4 sm:grid-cols-3"> <h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetails.upstreamErrors') }}</h3>
<div> <div class="text-xs text-gray-500 dark:text-gray-400" v-if="correlatedUpstreamLoading">{{ t('common.loading') }}</div>
<div class="text-xs font-bold uppercase text-gray-400">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">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>
<div v-if="detail.upstream_error_detail" class="mt-4"> <div v-if="!correlatedUpstreamLoading && !correlatedUpstreamErrors.length" class="mt-3 text-sm text-gray-500 dark:text-gray-400">
<div class="text-xs font-bold uppercase text-gray-400">detail</div> {{ t('common.noData') }}
<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>
<div v-if="detail.upstream_errors" class="mt-5"> <div v-else class="mt-4 space-y-3">
<div class="mb-2 text-xs font-bold uppercase text-gray-400">upstream_errors</div> <div
v-for="(ev, idx) in correlatedUpstreamErrors"
<div v-if="upstreamErrors.length" class="space-y-3"> :key="ev.id"
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
v-for="(ev, idx) in upstreamErrors" >
:key="idx" <div class="flex flex-wrap items-center justify-between gap-2">
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800" <div class="text-xs font-black text-gray-900 dark:text-white">
> #{{ idx + 1 }}
<div class="flex flex-wrap items-center justify-between gap-2"> <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 class="text-xs font-black text-gray-800 dark:text-gray-100"> </div>
#{{ idx + 1 }} <span v-if="ev.kind" class="font-mono">{{ ev.kind }}</span> <div class="flex items-center gap-2">
</div>
<div class="font-mono text-xs text-gray-500 dark:text-gray-400"> <div class="font-mono text-xs text-gray-500 dark:text-gray-400">
{{ ev.at_unix_ms ? formatDateTime(new Date(ev.at_unix_ms)) : '' }} {{ ev.status_code ?? '' }}
</div> </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>
</button>
</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-3"> <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">account_id:</span> <span class="font-mono">{{ ev.account_id ?? '—' }}</span></div> <div>
<div><span class="text-gray-400">status:</span> <span class="font-mono">{{ ev.upstream_status_code ?? '—' }}</span></div> <span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span>
<div class="break-all"> <span class="ml-1 font-mono">{{ ev.status_code ?? '—' }}</span>
<span class="text-gray-400">request_id:</span> <span class="font-mono">{{ ev.upstream_request_id || '—' }}</span>
</div>
</div> </div>
<div>
<div v-if="ev.message" class="mt-2 break-words text-sm font-medium text-gray-900 dark:text-white"> <span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span>
{{ ev.message }} <span class="ml-1 font-mono">{{ ev.request_id || ev.client_request_id || '—' }}</span>
</div> </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>
</div>
<pre <div v-if="ev.message" class="mt-3 break-words text-sm font-medium text-gray-900 dark:text-white">{{ ev.message }}</div>
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>
<!-- Request body --> <pre
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"> v-if="expandedUpstreamDetailIds.has(ev.id)"
<div class="flex items-center justify-between"> 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"
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.requestBody') }}</h3> ><code>{{ prettyJSON(getUpstreamResponsePreview(ev)) }}</code></pre>
<div
v-if="detail.request_body_truncated"
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.ops.errorDetail.trimmed') }}
</div> </div>
</div> </div>
<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.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>
</div> </div>
</div> </div>
</BaseDialog> </BaseDialog>
<ConfirmDialog
:show="showRetryConfirm"
:title="t('admin.ops.errorDetail.confirmRetry')"
:message="retryConfirmMessage"
@confirm="runConfirmedRetry"
@cancel="cancelRetry"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' 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 { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail, type OpsRetryMode } from '@/api/admin/ops' import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { getSeverityClass } from '../utils/opsFormatters'
interface Props { interface Props {
show: boolean show: boolean
errorId: number | null errorId: number | null
errorType?: 'request' | 'upstream'
} }
interface Emits { interface Emits {
...@@ -315,53 +187,76 @@ const appStore = useAppStore() ...@@ -315,53 +187,76 @@ const appStore = useAppStore()
const loading = ref(false) const loading = ref(false)
const detail = ref<OpsErrorDetail | null>(null) const detail = ref<OpsErrorDetail | null>(null)
const retrying = ref(false) const showUpstreamList = computed(() => props.errorType === 'request')
const showRetryConfirm = ref(false)
const pendingRetryMode = ref<OpsRetryMode>('client') const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
const pinnedAccountIdInput = ref('') const primaryResponseBody = computed(() => {
const pinnedAccountId = computed<number | null>(() => { if (!detail.value) return ''
const raw = String(pinnedAccountIdInput.value || '').trim() if (props.errorType === 'upstream') {
if (!raw) return null return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
const n = Number.parseInt(raw, 10) }
return Number.isFinite(n) && n > 0 ? n : null return detail.value.error_body || ''
}) })
const title = computed(() => { const title = computed(() => {
if (!props.errorId) return 'Error Detail' if (!props.errorId) return t('admin.ops.errorDetail.title')
return `Error #${props.errorId}` return t('admin.ops.errorDetail.titleWithId', { id: String(props.errorId) })
}) })
const emptyText = computed(() => 'No error selected.') const emptyText = computed(() => t('admin.ops.errorDetail.noErrorSelected'))
type UpstreamErrorEvent = { function isUpstreamError(d: OpsErrorDetail | null): boolean {
at_unix_ms?: number if (!d) return false
platform?: string const phase = String(d.phase || '').toLowerCase()
account_id?: number const owner = String(d.error_owner || '').toLowerCase()
upstream_status_code?: number return phase === 'upstream' && owner === 'provider'
upstream_request_id?: string
kind?: string
message?: string
detail?: string
} }
const upstreamErrors = computed<UpstreamErrorEvent[]>(() => { const correlatedUpstream = ref<OpsErrorDetail[]>([])
const raw = detail.value?.upstream_errors const correlatedUpstreamLoading = ref(false)
if (!raw) return []
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()
}
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 { try {
const parsed = JSON.parse(raw) const res = await opsAPI.listRequestErrorUpstreamErrors(
return Array.isArray(parsed) ? (parsed as UpstreamErrorEvent[]) : [] requestErrorId,
} catch { { page: 1, page_size: 100, view: 'all' },
return [] { 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() { function close() {
emit('update:show', false) emit('update:show', false)
} }
function prettyJSON(raw?: string): string { function prettyJSON(raw?: string): string {
if (!raw) return t('admin.ops.errorDetail.na') if (!raw) return 'N/A'
try { try {
return JSON.stringify(JSON.parse(raw), null, 2) return JSON.stringify(JSON.parse(raw), null, 2)
} catch { } catch {
...@@ -372,15 +267,9 @@ function prettyJSON(raw?: string): string { ...@@ -372,15 +267,9 @@ function prettyJSON(raw?: string): string {
async function fetchDetail(id: number) { async function fetchDetail(id: number) {
loading.value = true loading.value = true
try { try {
const d = await opsAPI.getErrorLogDetail(id) 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 detail.value = d
// Default pinned account from error log if present.
if (d.account_id && d.account_id > 0) {
pinnedAccountIdInput.value = String(d.account_id)
} else {
pinnedAccountIdInput.value = ''
}
} catch (err: any) { } catch (err: any) {
detail.value = null detail.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail')) appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail'))
...@@ -397,30 +286,18 @@ watch( ...@@ -397,30 +286,18 @@ watch(
return return
} }
if (typeof id === 'number' && id > 0) { if (typeof id === 'number' && id > 0) {
expandedUpstreamDetailIds.value = new Set()
fetchDetail(id) fetchDetail(id)
if (props.errorType === 'request') {
fetchCorrelatedUpstreamErrors(id)
} else {
correlatedUpstream.value = []
}
} }
}, },
{ immediate: true } { immediate: true }
) )
function openRetryConfirm(mode: OpsRetryMode) {
pendingRetryMode.value = mode
showRetryConfirm.value = true
}
const retryConfirmMessage = computed(() => {
const mode = pendingRetryMode.value
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 statusClass = computed(() => {
const code = detail.value?.status_code ?? 0 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' 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'
...@@ -429,29 +306,4 @@ const statusClass = computed(() => { ...@@ -429,29 +306,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' 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
showRetryConfirm.value = false
retrying.value = true
try {
const req =
mode === 'upstream'
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined }
: { mode }
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)
} catch (err: any) {
appStore.showError(err?.message || t('admin.ops.retryFailed'))
} finally {
retrying.value = false
}
}
function cancelRetry() {
showRetryConfirm.value = false
}
</script> </script>
...@@ -22,23 +22,19 @@ const emit = defineEmits<{ ...@@ -22,23 +22,19 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const rows = ref<OpsErrorLog[]>([]) const rows = ref<OpsErrorLog[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(10)
const q = ref('') const q = ref('')
const statusCode = ref<number | null>(null) const statusCode = ref<number | 'other' | null>(null)
const phase = ref<string>('') const phase = ref<string>('')
const accountIdInput = ref<string>('') const errorOwner = ref<string>('')
const viewMode = ref<'errors' | 'excluded' | 'all'>('errors')
const accountId = computed<number | null>(() => {
const raw = String(accountIdInput.value || '').trim()
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
})
const modalTitle = computed(() => { const modalTitle = computed(() => {
return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors') return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors')
...@@ -48,20 +44,38 @@ const statusCodeSelectOptions = computed(() => { ...@@ -48,20 +44,38 @@ const statusCodeSelectOptions = computed(() => {
const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529] const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529]
return [ return [
{ value: null, label: t('common.all') }, { value: null, label: t('common.all') },
...codes.map((c) => ({ value: c, label: String(c) })) ...codes.map((c) => ({ value: c, label: String(c) })),
{ value: 'other', label: t('admin.ops.errorDetails.statusCodeOther') || 'Other' }
]
})
const ownerSelectOptions = computed(() => {
return [
{ value: '', label: t('common.all') },
{ value: 'provider', label: t('admin.ops.errorDetails.owner.provider') || 'provider' },
{ value: 'client', label: t('admin.ops.errorDetails.owner.client') || 'client' },
{ value: 'platform', label: t('admin.ops.errorDetails.owner.platform') || 'platform' }
]
})
const viewModeSelectOptions = computed(() => {
return [
{ value: 'errors', label: t('admin.ops.errorDetails.viewErrors') || 'errors' },
{ value: 'excluded', label: t('admin.ops.errorDetails.viewExcluded') || 'excluded' },
{ value: 'all', label: t('common.all') }
] ]
}) })
const phaseSelectOptions = computed(() => { const phaseSelectOptions = computed(() => {
const options = [ const options = [
{ value: '', label: t('common.all') }, { value: '', label: t('common.all') },
{ value: 'upstream', label: 'upstream' }, { value: 'request', label: t('admin.ops.errorDetails.phase.request') || 'request' },
{ value: 'network', label: 'network' }, { value: 'auth', label: t('admin.ops.errorDetails.phase.auth') || 'auth' },
{ value: 'routing', label: 'routing' }, { value: 'routing', label: t('admin.ops.errorDetails.phase.routing') || 'routing' },
{ value: 'auth', label: 'auth' }, { value: 'upstream', label: t('admin.ops.errorDetails.phase.upstream') || 'upstream' },
{ value: 'billing', label: 'billing' }, { value: 'network', label: t('admin.ops.errorDetails.phase.network') || 'network' },
{ value: 'concurrency', label: 'concurrency' }, { value: 'internal', label: t('admin.ops.errorDetails.phase.internal') || 'internal' }
{ value: 'internal', label: 'internal' }
] ]
return options return options
}) })
...@@ -78,7 +92,8 @@ async function fetchErrorLogs() { ...@@ -78,7 +92,8 @@ async function fetchErrorLogs() {
const params: Record<string, any> = { const params: Record<string, any> = {
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
time_range: props.timeRange time_range: props.timeRange,
view: viewMode.value
} }
const platform = String(props.platform || '').trim() const platform = String(props.platform || '').trim()
...@@ -86,13 +101,19 @@ async function fetchErrorLogs() { ...@@ -86,13 +101,19 @@ async function fetchErrorLogs() {
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
if (q.value.trim()) params.q = q.value.trim() if (q.value.trim()) params.q = q.value.trim()
if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value) if (statusCode.value === 'other') params.status_codes_other = '1'
if (typeof accountId.value === 'number') params.account_id = accountId.value else if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value)
const phaseVal = String(phase.value || '').trim() const phaseVal = String(phase.value || '').trim()
if (phaseVal) params.phase = phaseVal if (phaseVal) params.phase = phaseVal
const res = await opsAPI.listErrorLogs(params) const ownerVal = String(errorOwner.value || '').trim()
if (ownerVal) params.error_owner = ownerVal
const res = props.errorType === 'upstream'
? await opsAPI.listUpstreamErrors(params)
: await opsAPI.listRequestErrors(params)
rows.value = res.items || [] rows.value = res.items || []
total.value = res.total || 0 total.value = res.total || 0
} catch (err) { } catch (err) {
...@@ -104,21 +125,23 @@ async function fetchErrorLogs() { ...@@ -104,21 +125,23 @@ async function fetchErrorLogs() {
} }
} }
function resetFilters() { function resetFilters() {
q.value = '' q.value = ''
statusCode.value = null statusCode.value = null
phase.value = props.errorType === 'upstream' ? 'upstream' : '' phase.value = props.errorType === 'upstream' ? 'upstream' : ''
accountIdInput.value = '' errorOwner.value = ''
page.value = 1 viewMode.value = 'errors'
fetchErrorLogs() page.value = 1
} fetchErrorLogs()
}
watch( watch(
() => props.show, () => props.show,
(open) => { (open) => {
if (!open) return if (!open) return
page.value = 1 page.value = 1
pageSize.value = 20 pageSize.value = 10
resetFilters() resetFilters()
} }
) )
...@@ -154,16 +177,7 @@ watch( ...@@ -154,16 +177,7 @@ watch(
) )
watch( watch(
() => [statusCode.value, phase.value] as const, () => [statusCode.value, phase.value, errorOwner.value, viewMode.value] as const,
() => {
if (!props.show) return
page.value = 1
fetchErrorLogs()
}
)
watch(
() => accountId.value,
() => { () => {
if (!props.show) return if (!props.show) return
page.value = 1 page.value = 1
...@@ -177,12 +191,12 @@ watch( ...@@ -177,12 +191,12 @@ watch(
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<!-- Filters --> <!-- Filters -->
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700"> <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="grid grid-cols-8 gap-2">
<div class="lg:col-span-5"> <div class="col-span-2 compact-select">
<div class="relative group"> <div class="relative group">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg <svg
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500" class="h-3.5 w-3.5 text-gray-400 transition-colors group-focus-within:text-blue-500"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -193,32 +207,32 @@ watch( ...@@ -193,32 +207,32 @@ watch(
<input <input
v-model="q" v-model="q"
type="text" type="text"
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800" class="w-full rounded-lg border-gray-200 bg-gray-50/50 py-1.5 pl-9 pr-3 text-xs font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')" :placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
/> />
</div> </div>
</div> </div>
<div class="lg:col-span-2"> <div class="compact-select">
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" /> <Select :model-value="statusCode" :options="statusCodeSelectOptions" @update:model-value="statusCode = $event as any" />
</div> </div>
<div class="lg:col-span-2"> <div class="compact-select">
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" /> <Select :model-value="phase" :options="phaseSelectOptions" @update:model-value="phase = String($event ?? '')" />
</div> </div>
<div class="lg:col-span-2"> <div class="compact-select">
<input <Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" />
v-model="accountIdInput"
type="text"
inputmode="numeric"
class="input w-full text-sm"
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
/>
</div> </div>
<div class="lg:col-span-1 flex items-center justify-end">
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
<div class="compact-select">
<Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" />
</div>
<div class="flex items-center justify-end">
<button type="button" class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" @click="resetFilters">
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </button>
</div> </div>
...@@ -231,18 +245,26 @@ watch( ...@@ -231,18 +245,26 @@ watch(
{{ t('admin.ops.errorDetails.total') }} {{ total }} {{ t('admin.ops.errorDetails.total') }} {{ total }}
</div> </div>
<OpsErrorLogTable <OpsErrorLogTable
class="min-h-0 flex-1" class="min-h-0 flex-1"
:rows="rows" :rows="rows"
:total="total" :total="total"
:loading="loading" :loading="loading"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)" @openErrorDetail="emit('openErrorDetail', $event)"
@update:page="page = $event"
@update:pageSize="pageSize = $event" @update:page="page = $event"
/> @update:pageSize="pageSize = $event"
/>
</div> </div>
</div> </div>
</BaseDialog> </BaseDialog>
</template> </template>
<style>
.compact-select .select-trigger {
@apply py-1.5 px-3 text-xs rounded-lg;
}
</style>
<template> <template>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col bg-white dark:bg-dark-900">
<!-- Loading State -->
<div v-if="loading" class="flex flex-1 items-center justify-center py-10"> <div v-if="loading" class="flex flex-1 items-center justify-center py-10">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div> <div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
</div> </div>
<!-- Table Container -->
<div v-else class="flex min-h-0 flex-1 flex-col"> <div v-else class="flex min-h-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-auto"> <div class="min-h-0 flex-1 overflow-auto border-b border-gray-200 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="w-full border-separate border-spacing-0">
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50"> <thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-800">
<tr> <tr>
<th <th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
scope="col" {{ t('admin.ops.errorLog.time') }}
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.timeId') }}
</th> </th>
<th <th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
scope="col" {{ t('admin.ops.errorLog.type') }}
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.context') }}
</th> </th>
<th <th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
scope="col" {{ t('admin.ops.errorLog.platform') }}
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400" </th>
> <th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.model') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.group') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.user') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.status') }} {{ t('admin.ops.errorLog.status') }}
</th> </th>
<th <th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
scope="col"
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.message') }} {{ t('admin.ops.errorLog.message') }}
</th> </th>
<th <th class="border-b border-gray-200 px-4 py-2.5 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.latency') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.action') }} {{ t('admin.ops.errorLog.action') }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700"> <tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900"> <tr v-if="rows.length === 0">
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500"> <td colspan="9" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }} {{ t('admin.ops.errorLog.noErrors') }}
</td> </td>
</tr> </tr>
...@@ -57,59 +50,83 @@ ...@@ -57,59 +50,83 @@
<tr <tr
v-for="log in rows" v-for="log in rows"
:key="log.id" :key="log.id"
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900" class="group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
tabindex="0"
role="button"
@click="emit('openErrorDetail', log.id)" @click="emit('openErrorDetail', log.id)"
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
@keydown.space.prevent="emit('openErrorDetail', log.id)"
> >
<!-- Time & ID --> <!-- Time -->
<td class="px-6 py-4"> <td class="whitespace-nowrap px-4 py-2">
<div class="flex flex-col gap-0.5"> <el-tooltip :content="log.request_id || log.client_request_id" placement="top" :show-after="500">
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200"> <span class="font-mono text-xs font-medium text-gray-900 dark:text-gray-200">
{{ formatDateTime(log.created_at).split(' ')[1] }} {{ formatDateTime(log.created_at).split(' ')[1] }}
</span> </span>
<span </el-tooltip>
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
:title="log.request_id || log.client_request_id"
>
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
</span>
</div>
</td> </td>
<!-- Context (Platform/Model) --> <!-- Type -->
<td class="px-6 py-4"> <td class="whitespace-nowrap px-4 py-2">
<div class="flex flex-col items-start gap-1.5"> <span
<span :class="[
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300" 'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
> getTypeBadge(log).className
{{ log.platform || '-' }} ]"
</span> >
<span {{ getTypeBadge(log).label }}
v-if="log.model" </span>
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400" </td>
:title="log.model"
> <!-- Platform -->
<td class="whitespace-nowrap px-4 py-2">
<span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300">
{{ log.platform || '-' }}
</span>
</td>
<!-- Model -->
<td class="px-4 py-2">
<div class="max-w-[120px] truncate" :title="log.model">
<span v-if="log.model" class="font-mono text-[11px] text-gray-700 dark:text-gray-300">
{{ log.model }} {{ log.model }}
</span> </span>
<div <span v-else class="text-xs text-gray-400">-</span>
v-if="log.group_id || log.account_id"
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
>
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
</div>
</div> </div>
</td> </td>
<!-- Status & Severity --> <!-- Group -->
<td class="px-6 py-4"> <td class="px-4 py-2">
<div class="flex flex-wrap items-center gap-2"> <el-tooltip v-if="log.group_id" :content="t('admin.ops.errorLog.id') + ' ' + log.group_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.group_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- User / Account -->
<td class="px-4 py-2">
<template v-if="isUpstreamRow(log)">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.account_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
<template v-else>
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.user_email || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
</td>
<!-- Status -->
<td class="whitespace-nowrap px-4 py-2">
<div class="flex items-center gap-1.5">
<span <span
:class="[ :class="[
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', 'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getStatusClass(log.status_code) getStatusClass(log.status_code)
]" ]"
> >
...@@ -117,61 +134,47 @@ ...@@ -117,61 +134,47 @@
</span> </span>
<span <span
v-if="log.severity" v-if="log.severity"
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]" :class="['rounded px-1.5 py-0.5 text-[10px] font-bold', getSeverityClass(log.severity)]"
> >
{{ log.severity }} {{ log.severity }}
</span> </span>
</div> </div>
</td> </td>
<!-- Message --> <!-- Message (Response Content) -->
<td class="px-6 py-4"> <td class="px-4 py-2">
<div class="max-w-md lg:max-w-2xl"> <div class="max-w-[200px]">
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message"> <p class="truncate text-[11px] font-medium text-gray-600 dark:text-gray-400" :title="log.message">
{{ formatSmartMessage(log.message) || '-' }} {{ formatSmartMessage(log.message) || '-' }}
</p> </p>
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
<div v-if="log.phase" 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.phase }}</span>
</div>
<div v-if="log.client_ip" class="flex items-center gap-1">
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
</div>
</div>
</div>
</td>
<!-- Latency -->
<td class="px-6 py-4 text-right">
<div class="flex flex-col items-end">
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
</span>
</div> </div>
</td> </td>
<!-- Actions --> <!-- Actions -->
<td class="px-6 py-4 text-right" @click.stop> <td class="whitespace-nowrap px-4 py-2 text-right" @click.stop>
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)"> <div class="flex items-center justify-end gap-3">
{{ t('admin.ops.errorLog.details') }} <button type="button" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold" @click="emit('openErrorDetail', log.id)">
</button> {{ t('admin.ops.errorLog.details') }}
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination <!-- Pagination -->
v-if="total > 0" <div class="bg-gray-50/50 dark:bg-dark-800/50">
:total="total" <Pagination
:page="page" v-if="total > 0"
:page-size="pageSize" :total="total"
:page-size-options="[10, 20, 50, 100, 200, 500]" :page="page"
@update:page="emit('update:page', $event)" :page-size="pageSize"
@update:pageSize="emit('update:pageSize', $event)" :page-size-options="[10]"
/> @update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -184,6 +187,36 @@ import { getSeverityClass, formatDateTime } from '../utils/opsFormatters' ...@@ -184,6 +187,36 @@ import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n() const { t } = useI18n()
function isUpstreamRow(log: OpsErrorLog): boolean {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
return phase === 'upstream' && owner === 'provider'
}
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
if (isUpstreamRow(log)) {
return { label: t('admin.ops.errorLog.typeUpstream'), 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: t('admin.ops.errorLog.typeRequest'), 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: t('admin.ops.errorLog.typeAuth'), 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: t('admin.ops.errorLog.typeRouting'), 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: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
}
const fallback = phase || owner || t('common.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' }
}
interface Props { interface Props {
rows: OpsErrorLog[] rows: OpsErrorLog[]
total: number total: number
...@@ -208,14 +241,6 @@ function getStatusClass(code: number): string { ...@@ -208,14 +241,6 @@ function getStatusClass(code: number): string {
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' 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'
} }
function getLatencyClass(latency: number | null): string {
if (!latency) return 'text-gray-400'
if (latency > 10000) return 'text-red-600 font-black'
if (latency > 5000) return 'text-red-500 font-bold'
if (latency > 2000) return 'text-orange-500 font-medium'
return 'text-gray-600 dark:text-gray-400'
}
function formatSmartMessage(msg: string): string { function formatSmartMessage(msg: string): string {
if (!msg) return '' if (!msg) return ''
...@@ -231,10 +256,11 @@ function formatSmartMessage(msg: string): string { ...@@ -231,10 +256,11 @@ function formatSmartMessage(msg: string): string {
} }
} }
if (msg.includes('context deadline exceeded')) return 'context deadline exceeded' if (msg.includes('context deadline exceeded')) return t('admin.ops.errorLog.commonErrors.contextDeadlineExceeded')
if (msg.includes('connection refused')) return 'connection refused' if (msg.includes('connection refused')) return t('admin.ops.errorLog.commonErrors.connectionRefused')
if (msg.toLowerCase().includes('rate limit')) return 'rate limit' if (msg.toLowerCase().includes('rate limit')) return t('admin.ops.errorLog.commonErrors.rateLimit')
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
} }
</script> </script>
\ No newline at end of file
...@@ -38,7 +38,7 @@ const loading = ref(false) ...@@ -38,7 +38,7 @@ const loading = ref(false)
const items = ref<OpsRequestDetail[]>([]) const items = ref<OpsRequestDetail[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(10)
const close = () => emit('update:modelValue', false) const close = () => emit('update:modelValue', false)
...@@ -95,7 +95,7 @@ watch( ...@@ -95,7 +95,7 @@ watch(
(open) => { (open) => {
if (open) { if (open) {
page.value = 1 page.value = 1
pageSize.value = 20 pageSize.value = 10
fetchData() fetchData()
} }
} }
......
...@@ -50,27 +50,22 @@ function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationR ...@@ -50,27 +50,22 @@ function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationR
if (thresholds) { if (thresholds) {
if (thresholds.sla_percent_min != null) { if (thresholds.sla_percent_min != null) {
if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) { if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) {
errors.push('SLA 最低值必须在 0-100 之间') errors.push(t('admin.ops.runtime.validation.slaMinPercentRange'))
}
}
if (thresholds.latency_p99_ms_max != null) {
if (!Number.isFinite(thresholds.latency_p99_ms_max) || thresholds.latency_p99_ms_max < 0) {
errors.push('延迟 P99 最大值必须大于或等于 0')
} }
} }
if (thresholds.ttft_p99_ms_max != null) { if (thresholds.ttft_p99_ms_max != null) {
if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) { if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) {
errors.push('TTFT P99 最大值必须大于或等于 0') errors.push(t('admin.ops.runtime.validation.ttftP99MaxRange'))
} }
} }
if (thresholds.request_error_rate_percent_max != null) { if (thresholds.request_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) { if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) {
errors.push('请求错误率最大值必须在 0-100 之间') errors.push(t('admin.ops.runtime.validation.requestErrorRateMaxRange'))
} }
} }
if (thresholds.upstream_error_rate_percent_max != null) { if (thresholds.upstream_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) { if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) {
errors.push('上游错误率最大值必须在 0-100 之间') errors.push(t('admin.ops.runtime.validation.upstreamErrorRateMaxRange'))
} }
} }
} }
...@@ -163,7 +158,6 @@ function openAlertEditor() { ...@@ -163,7 +158,6 @@ function openAlertEditor() {
if (!draftAlert.value.thresholds) { if (!draftAlert.value.thresholds) {
draftAlert.value.thresholds = { draftAlert.value.thresholds = {
sla_percent_min: 99.5, sla_percent_min: 99.5,
latency_p99_ms_max: 2000,
ttft_p99_ms_max: 500, ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5, request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5 upstream_error_rate_percent_max: 5
...@@ -335,12 +329,12 @@ onMounted(() => { ...@@ -335,12 +329,12 @@ onMounted(() => {
</div> </div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">指标阈值配置</div> <div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.metricThresholds') }}</div>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">配置各项指标的告警阈值。超出阈值的指标将在看板上以红色显示。</p> <p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.metricThresholdsHint') }}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">SLA 最低值 (%)</div> <div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.slaMinPercent') }}</div>
<input <input
v-model.number="draftAlert.thresholds.sla_percent_min" v-model.number="draftAlert.thresholds.sla_percent_min"
type="number" type="number"
...@@ -350,24 +344,13 @@ onMounted(() => { ...@@ -350,24 +344,13 @@ onMounted(() => {
class="input" class="input"
placeholder="99.5" placeholder="99.5"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">SLA 低于此值时将显示为红色</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.slaMinPercentHint') }}</p>
</div> </div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">延迟 P99 最大值 (ms)</div>
<input
v-model.number="draftAlert.thresholds.latency_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
placeholder="2000"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">延迟 P99 高于此值时将显示为红色</p>
</div>
<div> <div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">TTFT P99 最大值 (ms)</div> <div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.ttftP99MaxMs') }}</div>
<input <input
v-model.number="draftAlert.thresholds.ttft_p99_ms_max" v-model.number="draftAlert.thresholds.ttft_p99_ms_max"
type="number" type="number"
...@@ -376,11 +359,11 @@ onMounted(() => { ...@@ -376,11 +359,11 @@ onMounted(() => {
class="input" class="input"
placeholder="500" placeholder="500"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">TTFT P99 高于此值时将显示为红色</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.ttftP99MaxMsHint') }}</p>
</div> </div>
<div> <div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">请求错误率最大值 (%)</div> <div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.requestErrorRateMaxPercent') }}</div>
<input <input
v-model.number="draftAlert.thresholds.request_error_rate_percent_max" v-model.number="draftAlert.thresholds.request_error_rate_percent_max"
type="number" type="number"
...@@ -390,11 +373,11 @@ onMounted(() => { ...@@ -390,11 +373,11 @@ onMounted(() => {
class="input" class="input"
placeholder="5" placeholder="5"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">请求错误率高于此值时将显示为红色</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.requestErrorRateMaxPercentHint') }}</p>
</div> </div>
<div> <div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">上游错误率最大值 (%)</div> <div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercent') }}</div>
<input <input
v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max" v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max"
type="number" type="number"
...@@ -404,7 +387,7 @@ onMounted(() => { ...@@ -404,7 +387,7 @@ onMounted(() => {
class="input" class="input"
placeholder="5" placeholder="5"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">上游错误率高于此值时将显示为红色</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercentHint') }}</p>
</div> </div>
</div> </div>
</div> </div>
...@@ -424,7 +407,7 @@ onMounted(() => { ...@@ -424,7 +407,7 @@ onMounted(() => {
v-model="draftAlert.silencing.global_until_rfc3339" v-model="draftAlert.silencing.global_until_rfc3339"
type="text" type="text"
class="input font-mono text-sm" class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')" placeholder="2026-01-05T00:00:00Z"
/> />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
</div> </div>
...@@ -496,7 +479,7 @@ onMounted(() => { ...@@ -496,7 +479,7 @@ onMounted(() => {
v-model="(entry as any).until_rfc3339" v-model="(entry as any).until_rfc3339"
type="text" type="text"
class="input font-mono text-sm" class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')" placeholder="2026-01-05T00:00:00Z"
/> />
</div> </div>
......
...@@ -32,7 +32,6 @@ const advancedSettings = ref<OpsAdvancedSettings | null>(null) ...@@ -32,7 +32,6 @@ const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 指标阈值配置 // 指标阈值配置
const metricThresholds = ref<OpsMetricThresholds>({ const metricThresholds = ref<OpsMetricThresholds>({
sla_percent_min: 99.5, sla_percent_min: 99.5,
latency_p99_ms_max: 2000,
ttft_p99_ms_max: 500, ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5, request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5 upstream_error_rate_percent_max: 5
...@@ -53,13 +52,12 @@ async function loadAllSettings() { ...@@ -53,13 +52,12 @@ async function loadAllSettings() {
advancedSettings.value = advanced advancedSettings.value = advanced
// 如果后端返回了阈值,使用后端的值;否则保持默认值 // 如果后端返回了阈值,使用后端的值;否则保持默认值
if (thresholds && Object.keys(thresholds).length > 0) { if (thresholds && Object.keys(thresholds).length > 0) {
metricThresholds.value = { metricThresholds.value = {
sla_percent_min: thresholds.sla_percent_min ?? 99.5, sla_percent_min: thresholds.sla_percent_min ?? 99.5,
latency_p99_ms_max: thresholds.latency_p99_ms_max ?? 2000, ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500, request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5, upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5 }
}
} }
} catch (err: any) { } catch (err: any) {
console.error('[OpsSettingsDialog] Failed to load settings', err) console.error('[OpsSettingsDialog] Failed to load settings', err)
...@@ -159,19 +157,16 @@ const validation = computed(() => { ...@@ -159,19 +157,16 @@ const validation = computed(() => {
// 验证指标阈值 // 验证指标阈值
if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) { if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) {
errors.push('SLA最低百分比必须在0-100之间') errors.push(t('admin.ops.settings.validation.slaMinPercentRange'))
}
if (metricThresholds.value.latency_p99_ms_max != null && metricThresholds.value.latency_p99_ms_max < 0) {
errors.push('延迟P99最大值必须大于等于0')
} }
if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) { if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) {
errors.push('TTFT P99最大值必须大于等于0') errors.push(t('admin.ops.settings.validation.ttftP99MaxRange'))
} }
if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) { if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) {
errors.push('请求错误率最大值必须在0-100之间') errors.push(t('admin.ops.settings.validation.requestErrorRateMaxRange'))
} }
if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) { if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) {
errors.push('上游错误率最大值必须在0-100之间') errors.push(t('admin.ops.settings.validation.upstreamErrorRateMaxRange'))
} }
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors }
...@@ -362,17 +357,6 @@ async function saveAllSettings() { ...@@ -362,17 +357,6 @@ async function saveAllSettings() {
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p> <p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p>
</div> </div>
<div>
<label class="input-label">{{ t('admin.ops.settings.latencyP99MaxMs') }}</label>
<input
v-model.number="metricThresholds.latency_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.latencyP99MaxMsHint') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label> <label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label>
...@@ -488,43 +472,63 @@ async function saveAllSettings() { ...@@ -488,43 +472,63 @@ async function saveAllSettings() {
</div> </div>
</div> </div>
<!-- 错误过滤 --> <!-- Error Filtering -->
<div class="space-y-3"> <div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">错误过滤</h5> <h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.errorFiltering') }}</h5>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">忽略 count_tokens 错误</label> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreCountTokensErrors') }}</label>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
启用后,count_tokens 请求的错误将不计入运维监控的统计和告警中(但仍会存储在数据库中) {{ t('admin.ops.settings.ignoreCountTokensErrorsHint') }}
</p> </p>
</div> </div>
<Toggle v-model="advancedSettings.ignore_count_tokens_errors" /> <Toggle v-model="advancedSettings.ignore_count_tokens_errors" />
</div> </div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreContextCanceled') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.ignoreContextCanceledHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.ignore_context_canceled" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreNoAvailableAccounts') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.ignoreNoAvailableAccountsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
</div>
</div> </div>
<!-- 自动刷新 --> <!-- Auto Refresh -->
<div class="space-y-3"> <div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">自动刷新</h5> <h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.autoRefresh') }}</h5>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">启用自动刷新</label> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableAutoRefresh') }}</label>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
自动刷新仪表板数据,启用后会定期拉取最新数据 {{ t('admin.ops.settings.enableAutoRefreshHint') }}
</p> </p>
</div> </div>
<Toggle v-model="advancedSettings.auto_refresh_enabled" /> <Toggle v-model="advancedSettings.auto_refresh_enabled" />
</div> </div>
<div v-if="advancedSettings.auto_refresh_enabled"> <div v-if="advancedSettings.auto_refresh_enabled">
<label class="input-label">刷新间隔</label> <label class="input-label">{{ t('admin.ops.settings.refreshInterval') }}</label>
<Select <Select
v-model="advancedSettings.auto_refresh_interval_seconds" v-model="advancedSettings.auto_refresh_interval_seconds"
:options="[ :options="[
{ value: 15, label: '15 秒' }, { value: 15, label: t('admin.ops.settings.refreshInterval15s') },
{ value: 30, label: '30 秒' }, { value: 30, label: t('admin.ops.settings.refreshInterval30s') },
{ value: 60, label: '60 秒' } { value: 60, label: t('admin.ops.settings.refreshInterval60s') }
]" ]"
/> />
</div> </div>
......
...@@ -61,7 +61,7 @@ const chartData = computed(() => { ...@@ -61,7 +61,7 @@ const chartData = computed(() => {
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)), labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
datasets: [ datasets: [
{ {
label: t('admin.ops.qps'), label: 'QPS',
data: props.points.map((p) => p.qps ?? 0), data: props.points.map((p) => p.qps ?? 0),
borderColor: colors.value.blue, borderColor: colors.value.blue,
backgroundColor: colors.value.blueAlpha, backgroundColor: colors.value.blueAlpha,
...@@ -183,7 +183,7 @@ function downloadChart() { ...@@ -183,7 +183,7 @@ function downloadChart() {
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
</h3> </h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>QPS</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
<template v-if="!props.fullscreen"> <template v-if="!props.fullscreen">
<button <button
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment