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