Commit 9584af5c authored by IanShaw027's avatar IanShaw027
Browse files

fix(ops): 优化错误日志查询和详情展示

- 新增 GetErrorLogByID 接口用于获取单个错误日志详情
- 优化 GetErrorLogs 过滤逻辑,简化参数处理
- 简化前端错误详情模态框代码,提升可维护性
- 更新相关 API 接口和 i18n 翻译
parent 5432087d
...@@ -19,6 +19,34 @@ type OpsHandler struct { ...@@ -19,6 +19,34 @@ type OpsHandler struct {
opsService *service.OpsService opsService *service.OpsService
} }
// GetErrorLogByID returns ops error log detail.
// GET /api/v1/admin/ops/errors/:id
func (h *OpsHandler) GetErrorLogByID(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid error id")
return
}
detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, detail)
}
const ( const (
opsListViewErrors = "errors" opsListViewErrors = "errors"
opsListViewExcluded = "excluded" opsListViewExcluded = "excluded"
...@@ -70,16 +98,25 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { ...@@ -70,16 +98,25 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
return return
} }
filter := &service.OpsErrorLogFilter{ filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
Page: page,
PageSize: pageSize,
}
if !startTime.IsZero() { if !startTime.IsZero() {
filter.StartTime = &startTime filter.StartTime = &startTime
} }
if !endTime.IsZero() { if !endTime.IsZero() {
filter.EndTime = &endTime filter.EndTime = &endTime
} }
filter.View = parseOpsViewParam(c)
filter.Phase = strings.TrimSpace(c.Query("phase"))
filter.Owner = strings.TrimSpace(c.Query("error_owner"))
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" { if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
filter.Platform = platform filter.Platform = platform
...@@ -100,22 +137,7 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { ...@@ -100,22 +137,7 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
} }
filter.AccountID = &id filter.AccountID = &id
} }
if phase := strings.TrimSpace(c.Query("phase")); phase != "" {
filter.Phase = phase
}
if owner := strings.TrimSpace(c.Query("error_owner")); owner != "" {
filter.Owner = owner
}
if source := strings.TrimSpace(c.Query("error_source")); source != "" {
filter.Source = source
}
filter.View = parseOpsViewParam(c)
// Legacy endpoint default: unresolved only (backward-compatible).
{
b := false
filter.Resolved = &b
}
if v := strings.TrimSpace(c.Query("resolved")); v != "" { if v := strings.TrimSpace(c.Query("resolved")); v != "" {
switch strings.ToLower(v) { switch strings.ToLower(v) {
case "1", "true", "yes": case "1", "true", "yes":
...@@ -129,9 +151,6 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { ...@@ -129,9 +151,6 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
return return
} }
} }
if q := strings.TrimSpace(c.Query("q")); q != "" {
filter.Query = q
}
if statusCodesStr := strings.TrimSpace(c.Query("status_codes")); statusCodesStr != "" { if statusCodesStr := strings.TrimSpace(c.Query("status_codes")); statusCodesStr != "" {
parts := strings.Split(statusCodesStr, ",") parts := strings.Split(statusCodesStr, ",")
out := make([]int, 0, len(parts)) out := make([]int, 0, len(parts))
...@@ -149,57 +168,15 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { ...@@ -149,57 +168,15 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
} }
filter.StatusCodes = out filter.StatusCodes = out
} }
if v := strings.TrimSpace(c.Query("status_codes_other")); v != "" {
switch strings.ToLower(v) {
case "1", "true", "yes":
filter.StatusCodesOther = true
case "0", "false", "no":
filter.StatusCodesOther = false
default:
response.BadRequest(c, "Invalid status_codes_other")
return
}
}
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }
response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize) response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
} }
// GetErrorLogByID returns a single error log detail.
// GET /api/v1/admin/ops/errors/:id
func (h *OpsHandler) GetErrorLogByID(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid error id")
return
}
detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, detail)
}
// ==================== New split endpoints ====================
// ListRequestErrors lists client-visible request errors. // ListRequestErrors lists client-visible request errors.
// GET /api/v1/admin/ops/request-errors // GET /api/v1/admin/ops/request-errors
func (h *OpsHandler) ListRequestErrors(c *gin.Context) { func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
...@@ -307,6 +284,104 @@ func (h *OpsHandler) GetRequestError(c *gin.Context) { ...@@ -307,6 +284,104 @@ func (h *OpsHandler) GetRequestError(c *gin.Context) {
h.GetErrorLogByID(c) h.GetErrorLogByID(c)
} }
// ListRequestErrorUpstreamErrors lists upstream error logs correlated to a request error.
// GET /api/v1/admin/ops/request-errors/:id/upstream-errors
func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid error id")
return
}
// Load request error to get correlation keys.
detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Correlate by request_id/client_request_id.
requestID := strings.TrimSpace(detail.RequestID)
clientRequestID := strings.TrimSpace(detail.ClientRequestID)
if requestID == "" && clientRequestID == "" {
response.Paginated(c, []*service.OpsErrorLog{}, 0, 1, 10)
return
}
page, pageSize := response.ParsePagination(c)
if pageSize > 500 {
pageSize = 500
}
// Keep correlation window wide enough so linked upstream errors
// are discoverable even when UI defaults to 1h elsewhere.
startTime, endTime, err := parseOpsTimeRange(c, "30d")
if err != nil {
response.BadRequest(c, err.Error())
return
}
filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
if !startTime.IsZero() {
filter.StartTime = &startTime
}
if !endTime.IsZero() {
filter.EndTime = &endTime
}
filter.View = "all"
filter.Phase = "upstream"
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
filter.Platform = platform
}
// Prefer exact match on request_id; if missing, fall back to client_request_id.
if requestID != "" {
filter.RequestID = requestID
} else {
filter.ClientRequestID = clientRequestID
}
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
// If client asks for details, expand each upstream error log to include upstream response fields.
includeDetail := strings.TrimSpace(c.Query("include_detail"))
if includeDetail == "1" || strings.EqualFold(includeDetail, "true") || strings.EqualFold(includeDetail, "yes") {
details := make([]*service.OpsErrorLogDetail, 0, len(result.Errors))
for _, item := range result.Errors {
if item == nil {
continue
}
d, err := h.opsService.GetErrorLogByID(c.Request.Context(), item.ID)
if err != nil || d == nil {
continue
}
details = append(details, d)
}
response.Paginated(c, details, int64(result.Total), result.Page, result.PageSize)
return
}
response.Paginated(c, result.Errors, int64(result.Total), result.Page, result.PageSize)
}
// RetryRequestErrorClient retries the client request based on stored request body. // RetryRequestErrorClient retries the client request based on stored request body.
// POST /api/v1/admin/ops/request-errors/:id/retry-client // POST /api/v1/admin/ops/request-errors/:id/retry-client
func (h *OpsHandler) RetryRequestErrorClient(c *gin.Context) { func (h *OpsHandler) RetryRequestErrorClient(c *gin.Context) {
......
...@@ -1008,6 +1008,16 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) { ...@@ -1008,6 +1008,16 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
args = append(args, pq.Array(known)) args = append(args, pq.Array(known))
clauses = append(clauses, "NOT (COALESCE(upstream_status_code, status_code, 0) = ANY($"+itoa(len(args))+"))") clauses = append(clauses, "NOT (COALESCE(upstream_status_code, status_code, 0) = ANY($"+itoa(len(args))+"))")
} }
// Exact correlation keys (preferred for request↔upstream linkage).
if rid := strings.TrimSpace(filter.RequestID); rid != "" {
args = append(args, rid)
clauses = append(clauses, "COALESCE(request_id,'') = $"+itoa(len(args)))
}
if crid := strings.TrimSpace(filter.ClientRequestID); crid != "" {
args = append(args, crid)
clauses = append(clauses, "COALESCE(client_request_id,'') = $"+itoa(len(args)))
}
if q := strings.TrimSpace(filter.Query); q != "" { if q := strings.TrimSpace(filter.Query); q != "" {
like := "%" + q + "%" like := "%" + q + "%"
args = append(args, like) args = append(args, like)
......
...@@ -123,6 +123,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { ...@@ -123,6 +123,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
// Request errors (client-visible failures) // Request errors (client-visible failures)
ops.GET("/request-errors", h.Admin.Ops.ListRequestErrors) ops.GET("/request-errors", h.Admin.Ops.ListRequestErrors)
ops.GET("/request-errors/:id", h.Admin.Ops.GetRequestError) ops.GET("/request-errors/:id", h.Admin.Ops.GetRequestError)
ops.GET("/request-errors/:id/upstream-errors", h.Admin.Ops.ListRequestErrorUpstreamErrors)
ops.POST("/request-errors/:id/retry-client", h.Admin.Ops.RetryRequestErrorClient) ops.POST("/request-errors/:id/retry-client", h.Admin.Ops.RetryRequestErrorClient)
ops.POST("/request-errors/:id/upstream-errors/:idx/retry", h.Admin.Ops.RetryRequestErrorUpstreamEvent) ops.POST("/request-errors/:id/upstream-errors/:idx/retry", h.Admin.Ops.RetryRequestErrorUpstreamEvent)
ops.PUT("/request-errors/:id/resolve", h.Admin.Ops.ResolveRequestError) ops.PUT("/request-errors/:id/resolve", h.Admin.Ops.ResolveRequestError)
......
...@@ -94,6 +94,10 @@ type OpsErrorLogFilter struct { ...@@ -94,6 +94,10 @@ type OpsErrorLogFilter struct {
Resolved *bool Resolved *bool
Query string Query string
// Optional correlation keys for exact matching.
RequestID string
ClientRequestID string
// View controls error categorization for list endpoints. // View controls error categorization for list endpoints.
// - errors: show actionable errors (exclude business-limited / 429 / 529) // - errors: show actionable errors (exclude business-limited / 429 / 529)
// - excluded: only show excluded errors // - excluded: only show excluded errors
......
...@@ -1037,6 +1037,17 @@ export async function updateUpstreamErrorResolved(errorId: number, resolved: boo ...@@ -1037,6 +1037,17 @@ export async function updateUpstreamErrorResolved(errorId: number, resolved: boo
await apiClient.put(`/admin/ops/upstream-errors/${errorId}/resolve`, { resolved }) await apiClient.put(`/admin/ops/upstream-errors/${errorId}/resolve`, { resolved })
} }
export async function listRequestErrorUpstreamErrors(
id: number,
params: OpsErrorListQueryParams = {},
options: { include_detail?: boolean } = {}
): Promise<PaginatedResponse<OpsErrorDetail>> {
const query: Record<string, any> = { ...params }
if (options.include_detail) query.include_detail = '1'
const { data } = await apiClient.get<PaginatedResponse<OpsErrorDetail>>(`/admin/ops/request-errors/${id}/upstream-errors`, { params: query })
return data
}
export async function listRequestDetails(params: OpsRequestDetailsParams): Promise<OpsRequestDetailsResponse> { export async function listRequestDetails(params: OpsRequestDetailsParams): Promise<OpsRequestDetailsResponse> {
const { data } = await apiClient.get<OpsRequestDetailsResponse>('/admin/ops/requests', { params }) const { data } = await apiClient.get<OpsRequestDetailsResponse>('/admin/ops/requests', { params })
return data return data
...@@ -1173,6 +1184,7 @@ export const opsAPI = { ...@@ -1173,6 +1184,7 @@ export const opsAPI = {
retryUpstreamError, retryUpstreamError,
updateRequestErrorResolved, updateRequestErrorResolved,
updateUpstreamErrorResolved, updateUpstreamErrorResolved,
listRequestErrorUpstreamErrors,
listRequestDetails, listRequestDetails,
listAlertRules, listAlertRules,
......
...@@ -129,6 +129,8 @@ export default { ...@@ -129,6 +129,8 @@ export default {
all: 'All', all: 'All',
none: 'None', none: 'None',
noData: 'No data', noData: 'No data',
expand: 'Expand',
collapse: 'Collapse',
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
critical: 'Critical', critical: 'Critical',
...@@ -2094,6 +2096,10 @@ export default { ...@@ -2094,6 +2096,10 @@ export default {
status: 'Status', status: 'Status',
requestId: 'Request ID' requestId: 'Request ID'
}, },
responsePreview: {
expand: 'Response (click to expand)',
collapse: 'Response (click to collapse)'
},
retryMeta: { retryMeta: {
used: 'Used', used: 'Used',
success: 'Success', success: 'Success',
......
...@@ -126,6 +126,8 @@ export default { ...@@ -126,6 +126,8 @@ export default {
all: '全部', all: '全部',
none: '', none: '',
noData: '暂无数据', noData: '暂无数据',
expand: '展开',
collapse: '收起',
success: '成功', success: '成功',
error: '错误', error: '错误',
critical: '严重', critical: '严重',
...@@ -2238,6 +2240,10 @@ export default { ...@@ -2238,6 +2240,10 @@ export default {
status: '状态码', status: '状态码',
requestId: '请求ID' requestId: '请求ID'
}, },
responsePreview: {
expand: '响应内容(点击展开)',
collapse: '响应内容(点击收起)'
},
retryMeta: { retryMeta: {
used: '使用账号', used: '使用账号',
success: '成功', success: '成功',
......
...@@ -400,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) { ...@@ -400,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) } requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title if (!requestDetailsPreset.value.title) requestDetailsPreset.value.title = basePreset.title
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showErrorModal.value = false
showRequestDetails.value = true showRequestDetails.value = true
} }
function openErrorDetails(kind: 'request' | 'upstream') { function openErrorDetails(kind: 'request' | 'upstream') {
errorDetailsType.value = kind errorDetailsType.value = kind
// Ensure only one modal visible at a time.
showRequestDetails.value = false
showErrorModal.value = false
showErrorDetails.value = true showErrorDetails.value = true
} }
...@@ -446,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) { ...@@ -446,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) {
function openError(id: number) { function openError(id: number) {
selectedErrorId.value = id selectedErrorId.value = id
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showRequestDetails.value = false
showErrorModal.value = true showErrorModal.value = true
} }
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import OpsErrorLogTable from './OpsErrorLogTable.vue' import OpsErrorLogTable from './OpsErrorLogTable.vue'
...@@ -22,23 +21,7 @@ const emit = defineEmits<{ ...@@ -22,23 +21,7 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore()
const retryingUpstream = ref<number | null>(null)
async function retryUpstreamError(id: number) {
try {
retryingUpstream.value = id
const res = await opsAPI.retryUpstreamError(id)
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
appStore.showSuccess(summary)
page.value = 1
await fetchErrorLogs()
} catch (err: any) {
appStore.showError(err?.message || t('admin.ops.retryFailed'))
} finally {
retryingUpstream.value = null
}
}
const loading = ref(false) const loading = ref(false)
const rows = ref<OpsErrorLog[]>([]) const rows = ref<OpsErrorLog[]>([])
...@@ -50,7 +33,6 @@ const q = ref('') ...@@ -50,7 +33,6 @@ const q = ref('')
const statusCode = ref<number | 'other' | null>(null) const statusCode = ref<number | 'other' | null>(null)
const phase = ref<string>('') const phase = ref<string>('')
const errorOwner = ref<string>('') const errorOwner = ref<string>('')
const resolvedStatus = ref<string>('unresolved')
const viewMode = ref<'errors' | 'excluded' | 'all'>('errors') const viewMode = ref<'errors' | 'excluded' | 'all'>('errors')
...@@ -76,13 +58,6 @@ const ownerSelectOptions = computed(() => { ...@@ -76,13 +58,6 @@ const ownerSelectOptions = computed(() => {
] ]
}) })
const resolvedSelectOptions = computed(() => {
return [
{ value: 'unresolved', label: t('admin.ops.errorDetails.unresolved') || 'unresolved' },
{ value: 'all', label: t('common.all') },
{ value: 'resolved', label: t('admin.ops.errorDetails.resolved') || 'resolved' }
]
})
const viewModeSelectOptions = computed(() => { const viewModeSelectOptions = computed(() => {
return [ return [
...@@ -135,10 +110,6 @@ async function fetchErrorLogs() { ...@@ -135,10 +110,6 @@ async function fetchErrorLogs() {
const ownerVal = String(errorOwner.value || '').trim() const ownerVal = String(errorOwner.value || '').trim()
if (ownerVal) params.error_owner = ownerVal if (ownerVal) params.error_owner = ownerVal
const resolvedVal = String(resolvedStatus.value || '').trim()
if (resolvedVal === 'resolved') params.resolved = 'true'
else if (resolvedVal === 'unresolved') params.resolved = 'false'
// 'all' -> omit
const res = props.errorType === 'upstream' const res = props.errorType === 'upstream'
? await opsAPI.listUpstreamErrors(params) ? await opsAPI.listUpstreamErrors(params)
...@@ -159,7 +130,6 @@ async function fetchErrorLogs() { ...@@ -159,7 +130,6 @@ async function fetchErrorLogs() {
statusCode.value = null statusCode.value = null
phase.value = props.errorType === 'upstream' ? 'upstream' : '' phase.value = props.errorType === 'upstream' ? 'upstream' : ''
errorOwner.value = '' errorOwner.value = ''
resolvedStatus.value = 'unresolved'
viewMode.value = 'errors' viewMode.value = 'errors'
page.value = 1 page.value = 1
fetchErrorLogs() fetchErrorLogs()
...@@ -207,7 +177,7 @@ watch( ...@@ -207,7 +177,7 @@ watch(
) )
watch( watch(
() => [statusCode.value, phase.value, errorOwner.value, resolvedStatus.value, viewMode.value] as const, () => [statusCode.value, phase.value, errorOwner.value, viewMode.value] as const,
() => { () => {
if (!props.show) return if (!props.show) return
page.value = 1 page.value = 1
...@@ -255,9 +225,7 @@ watch( ...@@ -255,9 +225,7 @@ watch(
<Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" /> <Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" />
</div> </div>
<div class="compact-select">
<Select :model-value="resolvedStatus" :options="resolvedSelectOptions" @update:model-value="resolvedStatus = String($event ?? 'unresolved')" />
</div>
<div class="compact-select"> <div class="compact-select">
<Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" /> <Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" />
...@@ -285,7 +253,7 @@ watch( ...@@ -285,7 +253,7 @@ watch(
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)" @openErrorDetail="emit('openErrorDetail', $event)"
@retryUpstream="retryUpstreamError"
@update:page="page = $event" @update:page="page = $event"
@update:pageSize="pageSize = $event" @update:pageSize="pageSize = $event"
/> />
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment