Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
90bce60b
Commit
90bce60b
authored
Jan 15, 2026
by
yangjianbo
Browse files
feat: merge dev
parent
a458e684
Changes
107
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
View file @
90bce60b
...
...
@@ -12,12 +12,12 @@
</div>
<div
v-else
class=
"space-y-6 p-6"
>
<!--
Top
Summary -->
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-4"
>
<!-- 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"
>
{{
detail
.
request_id
||
detail
.
client_request_i
d
||
'
—
'
}}
{{
requestI
d
||
'
—
'
}}
</div>
</div>
...
...
@@ -29,277 +29,149 @@
</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
class=
"text-xs font-bold uppercase tracking-wider text-gray-400"
>
{{
isUpstreamError
(
detail
)
?
t
(
'
admin.ops.errorDetail.account
'
)
:
t
(
'
admin.ops.errorDetail.user
'
)
}}
</div>
<div
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
detail
.
type
||
'
—
'
}}
<div
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
<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
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
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>
<!-- Message -->
<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.message
'
)
}}
</h3>
<div
class=
"text-sm font-medium text-gray-800 dark:text-gray-200 break-words"
>
{{
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
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
>
<!--
Timings
(
best
-
effort
fields
)
-->
<
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
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
>
<!--
Retry
-->
<
div
class
=
"
rounded-xl bg-gray-50 p-6 dark:bg-dark-900
"
>
<
div
class
=
"
flex flex-col justify-between gap-4 md:flex-row md:items-start
"
>
<
div
class
=
"
space-y-1
"
>
<
h3
class
=
"
text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.ops.errorDetail.retry
'
)
}}
<
/h3
>
<
div
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.ops.errorDetail.retryNote1
'
)
}}
<
/div
>
<
/div
>
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
:
disabled
=
"
retrying
"
@
click
=
"
openRetryConfirm('client')
"
>
{{
t
(
'
admin.ops.errorDetail.retryClient
'
)
}}
<
/button
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
:
disabled
=
"
retrying || !pinnedAccountId
"
@
click
=
"
openRetryConfirm('upstream')
"
:
title
=
"
pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')
"
>
{{
t
(
'
admin.ops.errorDetail.retryUpstream
'
)
}}
<
/button
>
<div
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
=
"
mt-4 grid grid-cols-1 gap-4 md:grid-cols-3
"
>
<
div
class
=
"
md:col-span-1
"
>
<
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
"
:
placeholder
=
"
t('admin.ops.errorDetail.pinnedAccountIdHint')
"
/>
<
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
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>
</div>
<!--
Upstream
errors
-->
<
div
v
-
if
=
"
detail.upstream_status_code || detail.upstream_error_message || detail.upstream_error_detail || detail.upstream_errors
"
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
"
>
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
>
<!-- 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>
<!-- 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"
>
<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
v
-
if
=
"
detail.upstream_error_detail
"
class
=
"
mt-4
"
>
<
div
class
=
"
text-xs font-bold uppercase text-gray-400
"
>
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
v-if=
"!correlatedUpstreamLoading && !correlatedUpstreamErrors.length"
class=
"mt-3 text-sm text-gray-500 dark:text-gray-400"
>
{{ t('common.noData') }}
</div>
<
div
v
-
if
=
"
detail.upstream_errors
"
class
=
"
mt-5
"
>
<
div
class
=
"
mb-2 text-xs font-bold uppercase text-gray-400
"
>
upstream_errors
<
/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
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
.
at_unix_ms
?
formatDateTime
(
new
Date
(
ev
.
at_unix_ms
))
:
''
}}
{{ 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>
</button>
</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
><
span
class
=
"
text-gray-400
"
>
account_id
:
<
/span> <span class="font-mono">{{ ev.account_id
??
'—'
}}
</
span
><
/div
>
<
div
><
span
class
=
"
text-gray-400
"
>
status
:
<
/span> <span class="font-mono">{{ ev.upstream_status_code
??
'—'
}}
</
span
><
/div
>
<
div
class
=
"
break-all
"
>
<
span
class
=
"
text-gray-400
"
>
request_id
:
<
/span> <span class="font-mono">{{ ev.upstream_request_id || '—'
}}
</
span
>
<
/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
v
-
if
=
"
ev.message
"
class
=
"
mt-2 break-words text-sm font-medium
text-gray-
9
00
dark:text-white
"
>
{{
ev
.
message
}}
<div>
<span
class=
"
text-gray-
4
00
"
>
{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:
</span
>
<span
class=
"ml-1 font-mono"
>
{{ ev.request_id || ev.client_request_id || '—' }}
</span>
</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
>
<div
v-if=
"ev.message"
class=
"mt-3 break-words text-sm font-medium text-gray-900 dark:text-white"
>
{{ ev.message }}
</div>
<!--
Request
body
-->
<
div
class
=
"
rounded-xl bg-gray-50 p-6 dark:bg-dark-900
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
h3
class
=
"
text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.ops.errorDetail.requestBody
'
)
}}
<
/h3
>
<
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
'
)
}}
<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>
</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>
</BaseDialog>
<
ConfirmDialog
:
show
=
"
showRetryConfirm
"
:
title
=
"
t('admin.ops.errorDetail.confirmRetry')
"
:
message
=
"
retryConfirmMessage
"
@
confirm
=
"
runConfirmedRetry
"
@
cancel
=
"
cancelRetry
"
/>
</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/co
mmon/ConfirmDialog
.vue
'
import
Icon
from
'
@/components/
i
co
ns/Icon
.vue
'
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
{
getSeverityClass
}
from
'
../utils/opsFormatters
'
interface
Props
{
show
:
boolean
errorId
:
number
|
null
errorType
?:
'
request
'
|
'
upstream
'
}
interface
Emits
{
...
...
@@ -315,53 +187,76 @@ const appStore = useAppStore()
const
loading
=
ref
(
false
)
const
detail
=
ref
<
OpsErrorDetail
|
null
>
(
null
)
const
retrying
=
ref
(
false
)
const
showRetryConfirm
=
ref
(
false
)
const
pendingRetryMode
=
ref
<
OpsRetryMode
>
(
'
client
'
)
const
showUpstreamList
=
computed
(()
=>
props
.
errorType
===
'
request
'
)
const
requestId
=
computed
(()
=>
detail
.
value
?.
request_id
||
detail
.
value
?.
client_request_id
||
'
'
)
const
p
innedAccountIdInput
=
ref
(
''
)
const
pinnedAccountId
=
computed
<
number
|
null
>
(()
=>
{
const
raw
=
String
(
pinnedAccountIdInput
.
value
||
''
).
trim
()
if
(
!
raw
)
return
null
const
n
=
Number
.
parseInt
(
raw
,
10
)
return
Number
.
isFinite
(
n
)
&&
n
>
0
?
n
:
null
const
p
rimaryResponseBody
=
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
||
''
})
const
title
=
computed
(()
=>
{
if
(
!
props
.
errorId
)
return
'
E
rror
Detail
'
return
`Error #${
props.errorId
}
`
if
(
!
props
.
errorId
)
return
t
(
'
admin.ops.e
rrorDetail
.title
'
)
return
t
(
'
admin.ops.errorDetail.titleWithId
'
,
{
id
:
String
(
props
.
errorId
)
})
})
const
emptyText
=
computed
(()
=>
'
No error selected.
'
)
type
UpstreamErrorEvent
=
{
at_unix_ms
?:
number
platform
?:
string
account_id
?:
number
upstream_status_code
?:
number
upstream_request_id
?:
string
kind
?:
string
message
?:
string
detail
?:
string
const
emptyText
=
computed
(()
=>
t
(
'
admin.ops.errorDetail.noErrorSelected
'
))
function
isUpstreamError
(
d
:
OpsErrorDetail
|
null
):
boolean
{
if
(
!
d
)
return
false
const
phase
=
String
(
d
.
phase
||
''
).
toLowerCase
()
const
owner
=
String
(
d
.
error_owner
||
''
).
toLowerCase
()
return
phase
===
'
upstream
'
&&
owner
===
'
provider
'
}
const
upstreamErrors
=
computed
<
UpstreamErrorEvent
[]
>
(()
=>
{
const
raw
=
detail
.
value
?.
upstream_errors
if
(
!
raw
)
return
[]
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
()
}
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
)
}
function
prettyJSON
(
raw
?:
string
):
string
{
if
(
!
raw
)
return
t
(
'
admin.ops.errorDetail.na
'
)
if
(
!
raw
)
return
'
N/A
'
try
{
return
JSON
.
stringify
(
JSON
.
parse
(
raw
),
null
,
2
)
}
catch
{
...
...
@@ -372,15 +267,9 @@ function prettyJSON(raw?: string): string {
async
function
fetchDetail
(
id
:
number
)
{
loading
.
value
=
true
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
// 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
)
{
detail
.
value
=
null
appStore
.
showError
(
err
?.
message
||
t
(
'
admin.ops.failedToLoadErrorDetail
'
))
...
...
@@ -397,30 +286,18 @@ watch(
return
}
if
(
typeof
id
===
'
number
'
&&
id
>
0
)
{
expandedUpstreamDetailIds
.
value
=
new
Set
()
fetchDetail
(
id
)
if
(
props
.
errorType
===
'
request
'
)
{
fetchCorrelatedUpstreamErrors
(
id
)
}
else
{
correlatedUpstream
.
value
=
[]
}
}
},
{
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
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
'
...
...
@@ -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
'
})
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
>
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
View file @
90bce60b
...
...
@@ -22,23 +22,19 @@ const emit = defineEmits<{
const
{
t
}
=
useI18n
()
const
loading
=
ref
(
false
)
const
rows
=
ref
<
OpsErrorLog
[]
>
([])
const
total
=
ref
(
0
)
const
page
=
ref
(
1
)
const
pageSize
=
ref
(
2
0
)
const
pageSize
=
ref
(
1
0
)
const
q
=
ref
(
''
)
const
statusCode
=
ref
<
number
|
null
>
(
null
)
const
statusCode
=
ref
<
number
|
'
other
'
|
null
>
(
null
)
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
(()
=>
{
return
props
.
errorType
===
'
upstream
'
?
t
(
'
admin.ops.errorDetails.upstreamErrors
'
)
:
t
(
'
admin.ops.errorDetails.requestErrors
'
)
...
...
@@ -48,20 +44,38 @@ const statusCodeSelectOptions = computed(() => {
const
codes
=
[
400
,
401
,
403
,
404
,
409
,
422
,
429
,
500
,
502
,
503
,
504
,
529
]
return
[
{
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
options
=
[
{
value
:
''
,
label
:
t
(
'
common.all
'
)
},
{
value
:
'
upstream
'
,
label
:
'
upstream
'
},
{
value
:
'
network
'
,
label
:
'
network
'
},
{
value
:
'
routing
'
,
label
:
'
routing
'
},
{
value
:
'
auth
'
,
label
:
'
auth
'
},
{
value
:
'
billing
'
,
label
:
'
billing
'
},
{
value
:
'
concurrency
'
,
label
:
'
concurrency
'
},
{
value
:
'
internal
'
,
label
:
'
internal
'
}
{
value
:
'
request
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.request
'
)
||
'
request
'
},
{
value
:
'
auth
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.auth
'
)
||
'
auth
'
},
{
value
:
'
routing
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.routing
'
)
||
'
routing
'
},
{
value
:
'
upstream
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.upstream
'
)
||
'
upstream
'
},
{
value
:
'
network
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.network
'
)
||
'
network
'
},
{
value
:
'
internal
'
,
label
:
t
(
'
admin.ops.errorDetails.phase.internal
'
)
||
'
internal
'
}
]
return
options
})
...
...
@@ -78,7 +92,8 @@ async function fetchErrorLogs() {
const
params
:
Record
<
string
,
any
>
=
{
page
:
page
.
value
,
page_size
:
pageSize
.
value
,
time_range
:
props
.
timeRange
time_range
:
props
.
timeRange
,
view
:
viewMode
.
value
}
const
platform
=
String
(
props
.
platform
||
''
).
trim
()
...
...
@@ -86,13 +101,19 @@ async function fetchErrorLogs() {
if
(
typeof
props
.
groupId
===
'
number
'
&&
props
.
groupId
>
0
)
params
.
group_id
=
props
.
groupId
if
(
q
.
value
.
trim
())
params
.
q
=
q
.
value
.
trim
()
if
(
typeof
statusCode
.
value
===
'
numb
er
'
)
params
.
status_codes
=
String
(
statusCode
.
value
)
if
(
typeof
accountId
.
value
===
'
number
'
)
params
.
account_id
=
accountId
.
value
if
(
statusCode
.
value
===
'
oth
er
'
)
params
.
status_codes
_other
=
'
1
'
else
if
(
typeof
statusCode
.
value
===
'
number
'
)
params
.
status_codes
=
String
(
statusCode
.
value
)
const
phaseVal
=
String
(
phase
.
value
||
''
).
trim
()
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
||
[]
total
.
value
=
res
.
total
||
0
}
catch
(
err
)
{
...
...
@@ -104,21 +125,23 @@ async function fetchErrorLogs() {
}
}
function
resetFilters
()
{
q
.
value
=
''
statusCode
.
value
=
null
phase
.
value
=
props
.
errorType
===
'
upstream
'
?
'
upstream
'
:
''
accountIdInput
.
value
=
''
page
.
value
=
1
fetchErrorLogs
()
}
function
resetFilters
()
{
q
.
value
=
''
statusCode
.
value
=
null
phase
.
value
=
props
.
errorType
===
'
upstream
'
?
'
upstream
'
:
''
errorOwner
.
value
=
''
viewMode
.
value
=
'
errors
'
page
.
value
=
1
fetchErrorLogs
()
}
watch
(
()
=>
props
.
show
,
(
open
)
=>
{
if
(
!
open
)
return
page
.
value
=
1
pageSize
.
value
=
2
0
pageSize
.
value
=
1
0
resetFilters
()
}
)
...
...
@@ -154,16 +177,7 @@ watch(
)
watch
(
()
=>
[
statusCode
.
value
,
phase
.
value
]
as
const
,
()
=>
{
if
(
!
props
.
show
)
return
page
.
value
=
1
fetchErrorLogs
()
}
)
watch
(
()
=>
accountId
.
value
,
()
=>
[
statusCode
.
value
,
phase
.
value
,
errorOwner
.
value
,
viewMode
.
value
]
as
const
,
()
=>
{
if
(
!
props
.
show
)
return
page
.
value
=
1
...
...
@@ -177,12 +191,12 @@ watch(
<div
class=
"flex h-full min-h-0 flex-col"
>
<!-- Filters -->
<div
class=
"mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700"
>
<div
class=
"grid grid-cols-
1
gap-
4 lg:grid-cols-1
2"
>
<div
class=
"
lg:
col-span-
5
"
>
<div
class=
"grid grid-cols-
8
gap-2"
>
<div
class=
"col-span-
2 compact-select
"
>
<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
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"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
...
...
@@ -193,32 +207,32 @@ watch(
<input
v-model=
"q"
type=
"text"
class=
"w-full rounded-
2x
l border-gray-200 bg-gray-50/50 py-
2
pl-
10
pr-
4
text-s
m
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-l
g
border-gray-200 bg-gray-50/50 py-
1.5
pl-
9
pr-
3
text-
x
s 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')"
/>
</div>
</div>
<div
class=
"
lg:col-span-2
"
>
<Select
:model-value=
"statusCode"
:options=
"statusCodeSelectOptions"
class=
"w-full"
@
update:model-value=
"statusCode = $event as any"
/>
<div
class=
"
compact-select
"
>
<Select
:model-value=
"statusCode"
:options=
"statusCodeSelectOptions"
@
update:model-value=
"statusCode = $event as any"
/>
</div>
<div
class=
"
lg:col-span-2
"
>
<Select
:model-value=
"phase"
:options=
"phaseSelectOptions"
class=
"w-full"
@
update:model-value=
"phase = String($event ?? '')"
/>
<div
class=
"
compact-select
"
>
<Select
:model-value=
"phase"
:options=
"phaseSelectOptions"
@
update:model-value=
"phase = String($event ?? '')"
/>
</div>
<div
class=
"lg:col-span-2"
>
<input
v-model=
"accountIdInput"
type=
"text"
inputmode=
"numeric"
class=
"input w-full text-sm"
:placeholder=
"t('admin.ops.errorDetails.accountIdPlaceholder')"
/>
<div
class=
"compact-select"
>
<Select
:model-value=
"errorOwner"
:options=
"ownerSelectOptions"
@
update:model-value=
"errorOwner = String($event ?? '')"
/>
</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
'
)
}}
</button>
</div>
...
...
@@ -231,18 +245,26 @@ watch(
{{
t
(
'
admin.ops.errorDetails.total
'
)
}}
{{
total
}}
</div>
<OpsErrorLogTable
class=
"min-h-0 flex-1"
:rows=
"rows"
:total=
"total"
:loading=
"loading"
:page=
"page"
:page-size=
"pageSize"
@
openErrorDetail=
"emit('openErrorDetail', $event)"
@
update:page=
"page = $event"
@
update:pageSize=
"pageSize = $event"
/>
<OpsErrorLogTable
class=
"min-h-0 flex-1"
:rows=
"rows"
:total=
"total"
:loading=
"loading"
:page=
"page"
:page-size=
"pageSize"
@
openErrorDetail=
"emit('openErrorDetail', $event)"
@
update:page=
"page = $event"
@
update:pageSize=
"pageSize = $event"
/>
</div>
</div>
</BaseDialog>
</
template
>
<
style
>
.compact-select
.select-trigger
{
@apply
py-1.5
px-3
text-xs
rounded-lg;
}
</
style
>
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
View file @
90bce60b
<
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
class=
"h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"
></div>
</div>
<!-- Table Container -->
<div
v-else
class=
"flex min-h-0 flex-1 flex-col"
>
<div
class=
"min-h-0 flex-1 overflow-auto"
>
<table
class=
"
min-
w-full
divide-y divide-gray-200 dark:divide-dark-70
0"
>
<thead
class=
"sticky top-0 z-10 bg-gray-50
/50
dark:bg-dark-800
/50
"
>
<div
class=
"min-h-0 flex-1 overflow-auto
border-b border-gray-200 dark:border-dark-700
"
>
<table
class=
"w-full
border-separate border-spacing-
0"
>
<thead
class=
"sticky top-0 z-10 bg-gray-50 dark:bg-dark-800"
>
<tr>
<th
scope=
"col"
class=
"whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.ops.errorLog.timeId
'
)
}}
<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.time
'
)
}}
</th>
<th
scope=
"col"
class=
"whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.ops.errorLog.context
'
)
}}
<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.type
'
)
}}
</th>
<th
scope=
"col"
class=
"whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<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.platform
'
)
}}
</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
'
)
}}
</th>
<th
scope=
"col"
class=
"px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<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.message
'
)
}}
</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.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"
>
<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"
>
{{
t
(
'
admin.ops.errorLog.action
'
)
}}
</th>
</tr>
</thead>
<tbody
class=
"divide-y divide-gray-100 dark:divide-dark-700"
>
<tr
v-if=
"rows.length === 0"
class=
"bg-white dark:bg-dark-900"
>
<td
colspan=
"
6
"
class=
"py-1
6
text-center text-sm text-gray-400 dark:text-dark-500"
>
<tr
v-if=
"rows.length === 0"
>
<td
colspan=
"
9
"
class=
"py-1
2
text-center text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.ops.errorLog.noErrors
'
)
}}
</td>
</tr>
...
...
@@ -57,59 +50,83 @@
<tr
v-for=
"log in rows"
: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"
tabindex=
"0"
role=
"button"
class=
"group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
@
click=
"emit('openErrorDetail', log.id)"
@
keydown.enter.prevent=
"emit('openErrorDetail', log.id)"
@
keydown.space.prevent=
"emit('openErrorDetail', log.id)"
>
<!-- Time
& ID
-->
<td
class=
"px-
6
py-
4
"
>
<
div
class=
"flex flex-col gap-0.5
"
>
<span
class=
"font-mono text-xs font-
bold
text-gray-900 dark:text-gray-200"
>
<!-- Time -->
<td
class=
"
whitespace-nowrap
px-
4
py-
2
"
>
<
el-tooltip
:content=
"log.request_id || log.client_request_id"
placement=
"top"
:show-after=
"500
"
>
<span
class=
"font-mono text-xs font-
medium
text-gray-900 dark:text-gray-200"
>
{{
formatDateTime
(
log
.
created_at
).
split
(
'
'
)[
1
]
}}
</span>
<span
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>
</el-tooltip>
</td>
<!-- Context (Platform/Model) -->
<td
class=
"px-6 py-4"
>
<div
class=
"flex flex-col items-start gap-1.5"
>
<span
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"
>
{{
log
.
platform
||
'
-
'
}}
</span>
<span
v-if=
"log.model"
class=
"max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
:title=
"log.model"
>
<!-- Type -->
<td
class=
"whitespace-nowrap px-4 py-2"
>
<span
:class=
"[
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getTypeBadge(log).className
]"
>
{{
getTypeBadge
(
log
).
label
}}
</span>
</td>
<!-- 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
}}
</span>
<div
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>
<span
v-else
class=
"text-xs text-gray-400"
>
-
</span>
</div>
</td>
<!-- Status & Severity -->
<td
class=
"px-6 py-4"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<!-- Group -->
<td
class=
"px-4 py-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
:class=
"[
'inline-flex items-center rounded
-lg
px-
2
py-
1
text-
xs
font-b
lack
ring-1 ring-inset
shadow-sm
',
'inline-flex items-center rounded px-
1.5
py-
0.5
text-
[10px]
font-b
old
ring-1 ring-inset',
getStatusClass(log.status_code)
]"
>
...
...
@@ -117,61 +134,47 @@
</span>
<span
v-if=
"log.severity"
:class=
"['rounded
-md
px-
2
py-0.5 text-[10px] font-b
lack shadow-sm
', getSeverityClass(log.severity)]"
:class=
"['rounded px-
1.5
py-0.5 text-[10px] font-b
old
', getSeverityClass(log.severity)]"
>
{{ log.severity }}
</span>
</div>
</td>
<!-- Message -->
<td
class=
"px-
6
py-
4
"
>
<div
class=
"max-w-
md lg:max-w-2xl
"
>
<p
class=
"truncate text-
xs font-semibold
text-gray-
7
00 dark:text-gray-
3
00"
:title=
"log.message"
>
<!-- Message
(Response Content)
-->
<td
class=
"px-
4
py-
2
"
>
<div
class=
"max-w-
[200px]
"
>
<p
class=
"truncate text-
[11px] font-medium
text-gray-
6
00 dark:text-gray-
4
00"
:title=
"log.message"
>
{{ formatSmartMessage(log.message) || '-' }}
</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>
</td>
<!-- Actions -->
<td
class=
"px-6 py-4 text-right"
@
click.stop
>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
@
click=
"emit('openErrorDetail', log.id)"
>
{{
t
(
'
admin.ops.errorLog.details
'
)
}}
</button>
<td
class=
"whitespace-nowrap px-4 py-2 text-right"
@
click.stop
>
<div
class=
"flex items-center justify-end gap-3"
>
<button
type=
"button"
class=
"text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold"
@
click=
"emit('openErrorDetail', log.id)"
>
{{ t('admin.ops.errorLog.details') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if=
"total > 0"
:total=
"total"
:page=
"page"
:page-size=
"pageSize"
:page-size-options=
"[10, 20, 50, 100, 200, 500]"
@
update:page=
"emit('update:page', $event)"
@
update:pageSize=
"emit('update:pageSize', $event)"
/>
<!-- Pagination -->
<div
class=
"bg-gray-50/50 dark:bg-dark-800/50"
>
<Pagination
v-if=
"total > 0"
:total=
"total"
:page=
"page"
:page-size=
"pageSize"
:page-size-options=
"[10]"
@
update:page=
"emit('update:page', $event)"
@
update:pageSize=
"emit('update:pageSize', $event)"
/>
</div>
</div>
</div>
</template>
...
...
@@ -184,6 +187,36 @@ import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
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
{
rows
:
OpsErrorLog
[]
total
:
number
...
...
@@ -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
'
}
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
{
if
(
!
msg
)
return
''
...
...
@@ -231,10 +256,11 @@ function formatSmartMessage(msg: string): string {
}
}
if
(
msg
.
includes
(
'
context deadline exceeded
'
))
return
'
context
d
eadline
e
xceeded
'
if
(
msg
.
includes
(
'
connection refused
'
))
return
'
connection
r
efused
'
if
(
msg
.
toLowerCase
().
includes
(
'
rate limit
'
))
return
'
rate
l
imit
'
if
(
msg
.
includes
(
'
context deadline exceeded
'
))
return
t
(
'
admin.ops.errorLog.commonErrors.
context
D
eadline
E
xceeded
'
)
if
(
msg
.
includes
(
'
connection refused
'
))
return
t
(
'
admin.ops.errorLog.commonErrors.
connection
R
efused
'
)
if
(
msg
.
toLowerCase
().
includes
(
'
rate limit
'
))
return
t
(
'
admin.ops.errorLog.commonErrors.
rate
L
imit
'
)
return
msg
.
length
>
200
?
msg
.
substring
(
0
,
200
)
+
'
...
'
:
msg
}
</
script
>
</
script
>
\ No newline at end of file
frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue
View file @
90bce60b
...
...
@@ -38,7 +38,7 @@ const loading = ref(false)
const
items
=
ref
<
OpsRequestDetail
[]
>
([])
const
total
=
ref
(
0
)
const
page
=
ref
(
1
)
const
pageSize
=
ref
(
2
0
)
const
pageSize
=
ref
(
1
0
)
const
close
=
()
=>
emit
(
'
update:modelValue
'
,
false
)
...
...
@@ -95,7 +95,7 @@ watch(
(
open
)
=>
{
if
(
open
)
{
page
.
value
=
1
pageSize
.
value
=
2
0
pageSize
.
value
=
1
0
fetchData
()
}
}
...
...
frontend/src/views/admin/ops/components/OpsRuntimeSettingsCard.vue
View file @
90bce60b
...
...
@@ -50,27 +50,22 @@ function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationR
if
(
thresholds
)
{
if
(
thresholds
.
sla_percent_min
!=
null
)
{
if
(
!
Number
.
isFinite
(
thresholds
.
sla_percent_min
)
||
thresholds
.
sla_percent_min
<
0
||
thresholds
.
sla_percent_min
>
100
)
{
errors
.
push
(
'
SLA 最低值必须在 0-100 之间
'
)
}
}
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
'
)
errors
.
push
(
t
(
'
admin.ops.runtime.validation.slaMinPercentRange
'
))
}
}
if
(
thresholds
.
ttft_p99_ms_max
!=
null
)
{
if
(
!
Number
.
isFinite
(
thresholds
.
ttft_p99_ms_max
)
||
thresholds
.
ttft_p99_ms_max
<
0
)
{
errors
.
push
(
'
TTFT P99 最大值必须大于或等于 0
'
)
errors
.
push
(
t
(
'
admin.ops.runtime.validation.ttftP99MaxRange
'
)
)
}
}
if
(
thresholds
.
request_error_rate_percent_max
!=
null
)
{
if
(
!
Number
.
isFinite
(
thresholds
.
request_error_rate_percent_max
)
||
thresholds
.
request_error_rate_percent_max
<
0
||
thresholds
.
request_error_rate_percent_max
>
100
)
{
errors
.
push
(
'
请求错误率最大值必须在 0-100 之间
'
)
errors
.
push
(
t
(
'
admin.ops.runtime.validation.requestErrorRateMaxRange
'
)
)
}
}
if
(
thresholds
.
upstream_error_rate_percent_max
!=
null
)
{
if
(
!
Number
.
isFinite
(
thresholds
.
upstream_error_rate_percent_max
)
||
thresholds
.
upstream_error_rate_percent_max
<
0
||
thresholds
.
upstream_error_rate_percent_max
>
100
)
{
errors
.
push
(
'
上游错误率最大值必须在 0-100 之间
'
)
errors
.
push
(
t
(
'
admin.ops.runtime.validation.upstreamErrorRateMaxRange
'
)
)
}
}
}
...
...
@@ -163,7 +158,6 @@ function openAlertEditor() {
if
(
!
draftAlert
.
value
.
thresholds
)
{
draftAlert
.
value
.
thresholds
=
{
sla_percent_min
:
99.5
,
latency_p99_ms_max
:
2000
,
ttft_p99_ms_max
:
500
,
request_error_rate_percent_max
:
5
,
upstream_error_rate_percent_max
:
5
...
...
@@ -335,12 +329,12 @@ onMounted(() => {
</div>
<div
class=
"rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50"
>
<div
class=
"mb-2 text-sm font-semibold text-gray-900 dark:text-white"
>
指标阈值配置
</div>
<p
class=
"mb-4 text-xs text-gray-500 dark:text-gray-400"
>
配置各项指标的告警阈值。超出阈值的指标将在看板上以红色显示。
</p>
<div
class=
"mb-2 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.ops.runtime.metricThresholds
'
)
}}
</div>
<p
class=
"mb-4 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.ops.runtime.metricThresholdsHint
'
)
}}
</p>
<div
class=
"grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div>
<div
class=
"mb-1 text-xs font-medium text-gray-600 dark:text-gray-300"
>
SLA 最低值 (%)
</div>
<div
class=
"mb-1 text-xs font-medium text-gray-600 dark:text-gray-300"
>
{{
t
(
'
admin.ops.runtime.slaMinPercent
'
)
}}
</div>
<input
v-model.number=
"draftAlert.thresholds.sla_percent_min"
type=
"number"
...
...
@@ -350,24 +344,13 @@ onMounted(() => {
class=
"input"
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
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
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
v-model.number=
"draftAlert.thresholds.ttft_p99_ms_max"
type=
"number"
...
...
@@ -376,11 +359,11 @@ onMounted(() => {
class=
"input"
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
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
v-model.number=
"draftAlert.thresholds.request_error_rate_percent_max"
type=
"number"
...
...
@@ -390,11 +373,11 @@ onMounted(() => {
class=
"input"
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
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
v-model.number=
"draftAlert.thresholds.upstream_error_rate_percent_max"
type=
"number"
...
...
@@ -404,7 +387,7 @@ onMounted(() => {
class=
"input"
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>
...
...
@@ -424,7 +407,7 @@ onMounted(() => {
v-model=
"draftAlert.silencing.global_until_rfc3339"
type=
"text"
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>
</div>
...
...
@@ -496,7 +479,7 @@ onMounted(() => {
v
-
model
=
"
(entry as any).until_rfc3339
"
type
=
"
text
"
class
=
"
input font-mono text-sm
"
:
placeholder
=
"
t('admin.ops.runtime.silencing.untilPlaceholder')
"
placeholder
=
"
2026-01-05T00:00:00Z
"
/>
<
/div
>
...
...
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
View file @
90bce60b
...
...
@@ -32,7 +32,6 @@ const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 指标阈值配置
const
metricThresholds
=
ref
<
OpsMetricThresholds
>
({
sla_percent_min
:
99.5
,
latency_p99_ms_max
:
2000
,
ttft_p99_ms_max
:
500
,
request_error_rate_percent_max
:
5
,
upstream_error_rate_percent_max
:
5
...
...
@@ -53,13 +52,12 @@ async function loadAllSettings() {
advancedSettings
.
value
=
advanced
// 如果后端返回了阈值,使用后端的值;否则保持默认值
if
(
thresholds
&&
Object
.
keys
(
thresholds
).
length
>
0
)
{
metricThresholds
.
value
=
{
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
,
request_error_rate_percent_max
:
thresholds
.
request_error_rate_percent_max
??
5
,
upstream_error_rate_percent_max
:
thresholds
.
upstream_error_rate_percent_max
??
5
}
metricThresholds
.
value
=
{
sla_percent_min
:
thresholds
.
sla_percent_min
??
99.5
,
ttft_p99_ms_max
:
thresholds
.
ttft_p99_ms_max
??
500
,
request_error_rate_percent_max
:
thresholds
.
request_error_rate_percent_max
??
5
,
upstream_error_rate_percent_max
:
thresholds
.
upstream_error_rate_percent_max
??
5
}
}
}
catch
(
err
:
any
)
{
console
.
error
(
'
[OpsSettingsDialog] Failed to load settings
'
,
err
)
...
...
@@ -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
))
{
errors
.
push
(
'
SLA最低百分比必须在0-100之间
'
)
}
if
(
metricThresholds
.
value
.
latency_p99_ms_max
!=
null
&&
metricThresholds
.
value
.
latency_p99_ms_max
<
0
)
{
errors
.
push
(
'
延迟P99最大值必须大于等于0
'
)
errors
.
push
(
t
(
'
admin.ops.settings.validation.slaMinPercentRange
'
))
}
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
))
{
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
))
{
errors
.
push
(
'
上游错误率最大值必须在0-100之间
'
)
errors
.
push
(
t
(
'
admin.ops.settings.validation.upstreamErrorRateMaxRange
'
)
)
}
return
{
valid
:
errors
.
length
===
0
,
errors
}
...
...
@@ -362,17 +357,6 @@ async function saveAllSettings() {
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.slaMinPercentHint
'
)
}}
</p>
</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>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.ttftP99MaxMs
'
)
}}
</label>
...
...
@@ -488,43 +472,63 @@ async function saveAllSettings() {
</div>
</div>
<!--
错误过滤
-->
<!--
Error Filtering
-->
<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>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
忽略 c
ount
_t
okens
错误
</label>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.ignoreC
ount
T
okens
Errors
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
启用后,c
ount
_t
okens
请求的错误将不计入运维监控的统计和告警中(但仍会存储在数据库中)
{{
t
(
'
admin.ops.settings.ignoreC
ount
T
okens
ErrorsHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.ignore_count_tokens_errors"
/>
</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>
<!--
自动刷新
-->
<!--
Auto Refresh
-->
<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>
<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"
>
自动刷新仪表板数据,启用后会定期拉取最新数据
{{
t
(
'
admin.ops.settings.enableAutoRefreshHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.auto_refresh_enabled"
/>
</div>
<div
v-if=
"advancedSettings.auto_refresh_enabled"
>
<label
class=
"input-label"
>
刷新间隔
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.ops.settings.refreshInterval
'
)
}}
</label>
<Select
v-model=
"advancedSettings.auto_refresh_interval_seconds"
:options=
"[
{ value: 15, label:
'15 秒'
},
{ value: 30, label:
'30 秒'
},
{ value: 60, label:
'60 秒'
}
{ value: 15, label:
t('admin.ops.settings.refreshInterval15s')
},
{ value: 30, label:
t('admin.ops.settings.refreshInterval30s')
},
{ value: 60, label:
t('admin.ops.settings.refreshInterval60s')
}
]"
/>
</div>
...
...
frontend/src/views/admin/ops/components/OpsThroughputTrendChart.vue
View file @
90bce60b
...
...
@@ -61,7 +61,7 @@ const chartData = computed(() => {
labels
:
props
.
points
.
map
((
p
)
=>
formatHistoryLabel
(
p
.
bucket_start
,
props
.
timeRange
)),
datasets
:
[
{
label
:
t
(
'
admin.ops.qps
'
)
,
label
:
'
QPS
'
,
data
:
props
.
points
.
map
((
p
)
=>
p
.
qps
??
0
),
borderColor
:
colors
.
value
.
blue
,
backgroundColor
:
colors
.
value
.
blueAlpha
,
...
...
@@ -183,7 +183,7 @@ function downloadChart() {
<HelpTooltip
v-if=
"!props.fullscreen"
:content=
"t('admin.ops.tooltips.throughputTrend')"
/>
</h3>
<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>
<template
v-if=
"!props.fullscreen"
>
<button
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment