fix(ops): 优化错误日志过滤和查询逻辑
后端改动: - 添加 resolved 参数默认值处理(向后兼容,默认显示未解决错误) - 新增 status_codes_other 查询参数支持 - 移除 service 层的高级设置过滤逻辑,简化错误日志查询流程 前端改动: - 完善错误日志相关组件的国际化支持 - 优化 Ops 监控面板和设置对话框的用户体验
This commit is contained in:
@@ -110,6 +110,12 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
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 != "" {
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes":
|
||||
@@ -143,6 +149,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
@@ -132,7 +132,6 @@ func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsEr
|
||||
pageSize = 500
|
||||
}
|
||||
|
||||
// buildOpsErrorLogsWhere may mutate filter (default resolved filter).
|
||||
where, args := buildOpsErrorLogsWhere(filter)
|
||||
countSQL := "SELECT COUNT(*) FROM ops_error_logs e " + where
|
||||
|
||||
@@ -933,15 +932,11 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
|
||||
}
|
||||
// ops_error_logs stores client-visible error requests (status>=400),
|
||||
// but we also persist "recovered" upstream errors (status<400) for upstream health visibility.
|
||||
// By default, keep list endpoints scoped to unresolved records if the caller didn't specify.
|
||||
// If Resolved is not specified, do not filter by resolved state (backward-compatible).
|
||||
resolvedFilter := (*bool)(nil)
|
||||
if filter != nil {
|
||||
resolvedFilter = filter.Resolved
|
||||
}
|
||||
if resolvedFilter == nil {
|
||||
f := false
|
||||
resolvedFilter = &f
|
||||
}
|
||||
// Keep list endpoints scoped to client errors unless explicitly filtering upstream phase.
|
||||
if phaseFilter != "upstream" {
|
||||
clauses = append(clauses, "COALESCE(status_code, 0) >= 400")
|
||||
@@ -1007,6 +1002,11 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
|
||||
if len(filter.StatusCodes) > 0 {
|
||||
args = append(args, pq.Array(filter.StatusCodes))
|
||||
clauses = append(clauses, "COALESCE(upstream_status_code, status_code, 0) = ANY($"+itoa(len(args))+")")
|
||||
} else if filter.StatusCodesOther {
|
||||
// "Other" means: status codes not in the common list.
|
||||
known := []int{400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529}
|
||||
args = append(args, pq.Array(known))
|
||||
clauses = append(clauses, "NOT (COALESCE(upstream_status_code, status_code, 0) = ANY($"+itoa(len(args))+"))")
|
||||
}
|
||||
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||
like := "%" + q + "%"
|
||||
|
||||
@@ -86,12 +86,13 @@ type OpsErrorLogFilter struct {
|
||||
GroupID *int64
|
||||
AccountID *int64
|
||||
|
||||
StatusCodes []int
|
||||
Phase string
|
||||
Owner string
|
||||
Source string
|
||||
Resolved *bool
|
||||
Query string
|
||||
StatusCodes []int
|
||||
StatusCodesOther bool
|
||||
Phase string
|
||||
Owner string
|
||||
Source string
|
||||
Resolved *bool
|
||||
Query string
|
||||
|
||||
// View controls error categorization for list endpoints.
|
||||
// - errors: show actionable errors (exclude business-limited / 429 / 529)
|
||||
|
||||
@@ -261,64 +261,9 @@ func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply error filtering based on settings (for historical data)
|
||||
result = s.filterErrorLogsBySettings(ctx, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// filterErrorLogsBySettings filters error logs based on advanced settings.
|
||||
// This ensures that historical errors are also filtered when viewing the dashboard.
|
||||
func (s *OpsService) filterErrorLogsBySettings(ctx context.Context, result *OpsErrorLogList) *OpsErrorLogList {
|
||||
if result == nil || len(result.Errors) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
settings, err := s.GetOpsAdvancedSettings(ctx)
|
||||
if err != nil || settings == nil {
|
||||
// If we can't get settings, return unfiltered (fail open)
|
||||
return result
|
||||
}
|
||||
|
||||
filtered := make([]*OpsErrorLog, 0, len(result.Errors))
|
||||
for _, errLog := range result.Errors {
|
||||
if shouldFilterErrorLog(settings, errLog) {
|
||||
continue // Skip this error
|
||||
}
|
||||
filtered = append(filtered, errLog)
|
||||
}
|
||||
|
||||
// Update total count to reflect filtered results
|
||||
result.Errors = filtered
|
||||
result.Total = len(filtered)
|
||||
return result
|
||||
}
|
||||
|
||||
// shouldFilterErrorLog determines if an error log should be filtered based on settings.
|
||||
func shouldFilterErrorLog(settings *OpsAdvancedSettings, errLog *OpsErrorLog) bool {
|
||||
if settings == nil || errLog == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
msgLower := strings.ToLower(errLog.Message)
|
||||
|
||||
// Check if count_tokens errors should be ignored
|
||||
if settings.IgnoreCountTokensErrors && strings.Contains(errLog.RequestPath, "/count_tokens") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if context canceled errors should be ignored
|
||||
if settings.IgnoreContextCanceled && strings.Contains(msgLower, "context canceled") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if "no available accounts" errors should be ignored
|
||||
if settings.IgnoreNoAvailableAccounts && strings.Contains(msgLower, "no available accounts") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
|
||||
if err := s.RequireMonitoringEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -965,6 +965,7 @@ export type OpsErrorListQueryParams = {
|
||||
|
||||
q?: string
|
||||
status_codes?: string
|
||||
status_codes_other?: string
|
||||
}
|
||||
|
||||
// Legacy unified endpoints
|
||||
|
||||
@@ -2009,6 +2009,11 @@ export default {
|
||||
// Error Log
|
||||
errorLog: {
|
||||
timeId: 'Time / ID',
|
||||
commonErrors: {
|
||||
contextDeadlineExceeded: 'context deadline exceeded',
|
||||
connectionRefused: 'connection refused',
|
||||
rateLimit: 'rate limit'
|
||||
},
|
||||
time: 'Time',
|
||||
type: 'Type',
|
||||
context: 'Context',
|
||||
@@ -2038,12 +2043,64 @@ export default {
|
||||
requestErrors: 'Request Errors',
|
||||
unresolved: 'Unresolved',
|
||||
resolved: 'Resolved',
|
||||
viewErrors: 'Errors',
|
||||
viewExcluded: 'Excluded',
|
||||
statusCodeOther: 'Other',
|
||||
owner: {
|
||||
provider: 'Provider',
|
||||
client: 'Client',
|
||||
platform: 'Platform'
|
||||
},
|
||||
phase: {
|
||||
request: 'Request',
|
||||
auth: 'Auth',
|
||||
routing: 'Routing',
|
||||
upstream: 'Upstream',
|
||||
network: 'Network',
|
||||
internal: 'Internal'
|
||||
},
|
||||
total: 'Total:',
|
||||
searchPlaceholder: 'Search request_id / client_request_id / message',
|
||||
accountIdPlaceholder: 'account_id'
|
||||
},
|
||||
// Error Detail Modal
|
||||
errorDetail: {
|
||||
title: 'Error Detail',
|
||||
titleWithId: 'Error #{id}',
|
||||
noErrorSelected: 'No error selected.',
|
||||
resolution: 'Resolved:',
|
||||
pinnedToOriginalAccountId: 'Pinned to original account_id',
|
||||
missingUpstreamRequestBody: 'Missing upstream request body',
|
||||
failedToLoadRetryHistory: 'Failed to load retry history',
|
||||
failedToUpdateResolvedStatus: 'Failed to update resolved status',
|
||||
unsupportedRetryMode: 'Unsupported retry mode',
|
||||
classificationKeys: {
|
||||
phase: 'Phase',
|
||||
owner: 'Owner',
|
||||
source: 'Source',
|
||||
retryable: 'Retryable',
|
||||
resolvedAt: 'Resolved At',
|
||||
resolvedBy: 'Resolved By',
|
||||
resolvedRetryId: 'Resolved Retry',
|
||||
retryCount: 'Retry Count'
|
||||
},
|
||||
upstreamKeys: {
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
detail: 'Detail',
|
||||
upstreamErrors: 'Upstream Errors'
|
||||
},
|
||||
upstreamEvent: {
|
||||
account: 'Account',
|
||||
status: 'Status',
|
||||
requestId: 'Request ID'
|
||||
},
|
||||
retryMeta: {
|
||||
http: 'HTTP',
|
||||
used: 'Used',
|
||||
success: 'Success',
|
||||
pinned: 'Pinned'
|
||||
},
|
||||
loading: 'Loading…',
|
||||
requestId: 'Request ID',
|
||||
time: 'Time',
|
||||
@@ -2053,6 +2110,8 @@ export default {
|
||||
basicInfo: 'Basic Info',
|
||||
platform: 'Platform',
|
||||
model: 'Model',
|
||||
group: 'Group',
|
||||
account: 'Account',
|
||||
latency: 'Request Duration',
|
||||
ttft: 'TTFT',
|
||||
businessLimited: 'Business Limited',
|
||||
@@ -2083,6 +2142,7 @@ export default {
|
||||
retryNote1: 'Retry will use the same request body and parameters',
|
||||
retryNote2: 'If the original request failed due to account issues, pinned retry may still fail',
|
||||
retryNote3: 'Client retry will reselect an account',
|
||||
retryNote4: 'You can force retry for non-retryable errors, but it is not recommended',
|
||||
confirmRetryMessage: 'Confirm retry this request?',
|
||||
confirmRetryHint: 'Will resend with the same request parameters',
|
||||
forceRetry: 'I understand and want to force retry',
|
||||
@@ -2337,7 +2397,11 @@ export default {
|
||||
lockKeyRequired: 'Distributed lock key is required when lock is enabled',
|
||||
lockKeyPrefix: 'Distributed lock key must start with "{prefix}"',
|
||||
lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts',
|
||||
lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds'
|
||||
lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds',
|
||||
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
|
||||
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
|
||||
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
|
||||
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
@@ -2420,9 +2484,28 @@ export default {
|
||||
aggregation: 'Pre-aggregation Tasks',
|
||||
enableAggregation: 'Enable Pre-aggregation',
|
||||
aggregationHint: 'Pre-aggregation improves query performance for long time windows',
|
||||
errorFiltering: 'Error Filtering',
|
||||
ignoreCountTokensErrors: 'Ignore count_tokens errors',
|
||||
ignoreCountTokensErrorsHint: 'When enabled, errors from count_tokens requests will not be written to the error log.',
|
||||
ignoreContextCanceled: 'Ignore client disconnect errors',
|
||||
ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.',
|
||||
ignoreNoAvailableAccounts: 'Ignore no available accounts errors',
|
||||
ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.',
|
||||
refreshInterval: 'Refresh Interval',
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
refreshInterval60s: '60 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
retentionDaysRange: 'Retention days must be between 1-365 days'
|
||||
retentionDaysRange: 'Retention days must be between 1-365 days',
|
||||
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
|
||||
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
|
||||
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
|
||||
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
concurrency: {
|
||||
|
||||
@@ -2153,6 +2153,11 @@ export default {
|
||||
// Error Log
|
||||
errorLog: {
|
||||
timeId: '时间 / ID',
|
||||
commonErrors: {
|
||||
contextDeadlineExceeded: '请求超时',
|
||||
connectionRefused: '连接被拒绝',
|
||||
rateLimit: '触发限流'
|
||||
},
|
||||
time: '时间',
|
||||
type: '类型',
|
||||
context: '上下文',
|
||||
@@ -2182,12 +2187,64 @@ export default {
|
||||
requestErrors: '请求错误',
|
||||
unresolved: '未解决',
|
||||
resolved: '已解决',
|
||||
viewErrors: '错误',
|
||||
viewExcluded: '排除项',
|
||||
statusCodeOther: '其他',
|
||||
owner: {
|
||||
provider: '服务商',
|
||||
client: '客户端',
|
||||
platform: '平台'
|
||||
},
|
||||
phase: {
|
||||
request: '请求',
|
||||
auth: '认证',
|
||||
routing: '路由',
|
||||
upstream: '上游',
|
||||
network: '网络',
|
||||
internal: '内部'
|
||||
},
|
||||
total: '总计:',
|
||||
searchPlaceholder: '搜索 request_id / client_request_id / message',
|
||||
accountIdPlaceholder: 'account_id'
|
||||
},
|
||||
// Error Detail Modal
|
||||
errorDetail: {
|
||||
title: '错误详情',
|
||||
titleWithId: '错误 #{id}',
|
||||
noErrorSelected: '未选择错误。',
|
||||
resolution: '已解决:',
|
||||
pinnedToOriginalAccountId: '固定到原 account_id',
|
||||
missingUpstreamRequestBody: '缺少上游请求体',
|
||||
failedToLoadRetryHistory: '加载重试历史失败',
|
||||
failedToUpdateResolvedStatus: '更新解决状态失败',
|
||||
unsupportedRetryMode: '不支持的重试模式',
|
||||
classificationKeys: {
|
||||
phase: '阶段',
|
||||
owner: '归属方',
|
||||
source: '来源',
|
||||
retryable: '可重试',
|
||||
resolvedAt: '解决时间',
|
||||
resolvedBy: '解决人',
|
||||
resolvedRetryId: '解决重试ID',
|
||||
retryCount: '重试次数'
|
||||
},
|
||||
upstreamKeys: {
|
||||
status: '状态码',
|
||||
message: '消息',
|
||||
detail: '详情',
|
||||
upstreamErrors: '上游错误列表'
|
||||
},
|
||||
upstreamEvent: {
|
||||
account: '账号',
|
||||
status: '状态码',
|
||||
requestId: '请求ID'
|
||||
},
|
||||
retryMeta: {
|
||||
http: 'HTTP',
|
||||
used: '使用账号',
|
||||
success: '成功',
|
||||
pinned: '固定账号'
|
||||
},
|
||||
loading: '加载中…',
|
||||
requestId: '请求 ID',
|
||||
time: '时间',
|
||||
@@ -2197,6 +2254,8 @@ export default {
|
||||
basicInfo: '基本信息',
|
||||
platform: '平台',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
account: '账号',
|
||||
latency: '请求时长',
|
||||
ttft: 'TTFT',
|
||||
businessLimited: '业务限制',
|
||||
@@ -2227,6 +2286,7 @@ export default {
|
||||
retryNote1: '重试会使用相同的请求体和参数',
|
||||
retryNote2: '如果原请求失败是因为账号问题,固定重试可能仍会失败',
|
||||
retryNote3: '客户端重试会重新选择账号',
|
||||
retryNote4: '对不可重试的错误可以强制重试,但不推荐',
|
||||
confirmRetryMessage: '确认要重试该请求吗?',
|
||||
confirmRetryHint: '将使用相同的请求参数重新发送',
|
||||
forceRetry: '我已确认并理解强制重试风险',
|
||||
@@ -2481,7 +2541,11 @@ export default {
|
||||
lockKeyRequired: '启用分布式锁时必须填写 Lock Key',
|
||||
lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头',
|
||||
lockKeyHint: '建议以「{prefix}」开头以避免冲突',
|
||||
lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间'
|
||||
lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间',
|
||||
slaMinPercentRange: 'SLA 最低值必须在 0-100 之间',
|
||||
ttftP99MaxRange: 'TTFT P99 最大值必须大于或等于 0',
|
||||
requestErrorRateMaxRange: '请求错误率最大值必须在 0-100 之间',
|
||||
upstreamErrorRateMaxRange: '上游错误率最大值必须在 0-100 之间'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
@@ -2564,9 +2628,28 @@ export default {
|
||||
aggregation: '预聚合任务',
|
||||
enableAggregation: '启用预聚合任务',
|
||||
aggregationHint: '预聚合可提升长时间窗口查询性能',
|
||||
errorFiltering: '错误过滤',
|
||||
ignoreCountTokensErrors: '忽略 count_tokens 错误',
|
||||
ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。',
|
||||
ignoreContextCanceled: '忽略客户端断连错误',
|
||||
ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreNoAvailableAccounts: '忽略无可用账号错误',
|
||||
ignoreNoAvailableAccountsHint: '启用后,“No available accounts” 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。',
|
||||
refreshInterval: '刷新间隔',
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
refreshInterval60s: '60 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
retentionDaysRange: '保留天数必须在1-365天之间'
|
||||
retentionDaysRange: '保留天数必须在1-365天之间',
|
||||
slaMinPercentRange: 'SLA最低百分比必须在0-100之间',
|
||||
ttftP99MaxRange: 'TTFT P99最大值必须大于等于0',
|
||||
requestErrorRateMaxRange: '请求错误率最大值必须在0-100之间',
|
||||
upstreamErrorRateMaxRange: '上游错误率最大值必须在0-100之间'
|
||||
}
|
||||
},
|
||||
concurrency: {
|
||||
|
||||
@@ -826,7 +826,7 @@ function handleToolbarRefresh() {
|
||||
<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>
|
||||
<span>自动刷新: {{ props.autoRefreshCountdown }}s</span>
|
||||
<span>{{ t('admin.ops.settings.autoRefreshCountdown', { seconds: props.autoRefreshCountdown }) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1084,11 +1084,11 @@ function handleToolbarRefresh() {
|
||||
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeQps.toFixed(1) }}</span>
|
||||
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">QPS</span>
|
||||
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">{{ t('admin.ops.qps') }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span>
|
||||
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">TPS</span>
|
||||
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">{{ t('admin.ops.tps') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1101,11 +1101,11 @@ function handleToolbarRefresh() {
|
||||
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span>
|
||||
<span class="text-xs">QPS</span>
|
||||
<span class="text-xs">{{ t('admin.ops.qps') }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsPeakLabel }}</span>
|
||||
<span class="text-xs">TPS</span>
|
||||
<span class="text-xs">{{ t('admin.ops.tps') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1116,11 +1116,11 @@ function handleToolbarRefresh() {
|
||||
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span>
|
||||
<span class="text-xs">QPS</span>
|
||||
<span class="text-xs">{{ t('admin.ops.qps') }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsAvgLabel }}</span>
|
||||
<span class="text-xs">TPS</span>
|
||||
<span class="text-xs">{{ t('admin.ops.tps') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1195,7 +1195,7 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 2;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
|
||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.sla') }}</span>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" />
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
|
||||
</div>
|
||||
@@ -1242,33 +1242,33 @@ function handleToolbarRefresh() {
|
||||
<div class="text-3xl font-black text-gray-900 dark:text-white">
|
||||
{{ durationP99Ms ?? '-' }}
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||
<span class="text-xs font-bold text-gray-400">{{ t('admin.ops.msP99') }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P95:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p95') }}</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">{{ durationP95Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P90:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p90') }}</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">{{ durationP90Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P50:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p50') }}</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">{{ durationP50Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">Avg:</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">{{ durationAvgMs ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">Max:</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">{{ durationMaxMs ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1277,14 +1277,14 @@ function handleToolbarRefresh() {
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 5;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
|
||||
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.ttft') }}</span>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.ttft')" />
|
||||
</div>
|
||||
<button
|
||||
v-if="!props.fullscreen"
|
||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||
type="button"
|
||||
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
|
||||
@click="openDetails({ title: t('admin.ops.ttftLabel'), sort: 'duration_desc' })"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
@@ -1293,33 +1293,33 @@ function handleToolbarRefresh() {
|
||||
<div class="text-3xl font-black" :class="isTTFTAboveThreshold(ttftP99Ms) ? 'text-red-600 dark:text-red-400' : getTTFTColor(ttftP99Ms)">
|
||||
{{ ttftP99Ms ?? '-' }}
|
||||
</div>
|
||||
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
|
||||
<span class="text-xs font-bold text-gray-400">{{ t('admin.ops.msP99') }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P95:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p95') }}</span>
|
||||
<span class="font-bold" :class="getTTFTColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P90:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p90') }}</span>
|
||||
<span class="font-bold" :class="getTTFTColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">P50:</span>
|
||||
<span class="text-gray-500">{{ t('admin.ops.p50') }}</span>
|
||||
<span class="font-bold" :class="getTTFTColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">Avg:</span>
|
||||
<span class="font-bold" :class="getTTFTColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="text-gray-500">Max:</span>
|
||||
<span class="font-bold" :class="getTTFTColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span>
|
||||
<span class="text-gray-400">ms</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.ms') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1384,7 +1384,7 @@ function handleToolbarRefresh() {
|
||||
<!-- CPU -->
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.cpu') }}</div>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.cpu')" />
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
|
||||
@@ -1398,7 +1398,7 @@ function handleToolbarRefresh() {
|
||||
<!-- MEM -->
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div>
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.mem') }}</div>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" />
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-black" :class="memPercentClass">
|
||||
@@ -1416,7 +1416,7 @@ function handleToolbarRefresh() {
|
||||
<!-- DB -->
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div>
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.db') }}</div>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" />
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
|
||||
@@ -1433,7 +1433,7 @@ function handleToolbarRefresh() {
|
||||
<!-- Redis -->
|
||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.redis') }}</div>
|
||||
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.redis')" />
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<!-- 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="font-semibold text-gray-600 dark:text-gray-300">{{ t('admin.ops.errorDetail.resolution') }}</span>
|
||||
<span :class="(detail as any).resolved ? 'text-green-700 dark:text-green-400' : 'text-amber-700 dark:text-amber-300'">
|
||||
{{ (detail as any).resolved ? 'true' : 'false' }}
|
||||
{{ (detail as any).resolved ? t('admin.ops.errorDetails.resolved') : t('admin.ops.errorDetails.unresolved') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -28,7 +28,7 @@
|
||||
:disabled="loading"
|
||||
@click="markResolved(true)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.markResolved') || 'Mark resolved' }}
|
||||
{{ t('admin.ops.errorDetail.markResolved') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -37,17 +37,17 @@
|
||||
:disabled="loading"
|
||||
@click="markResolved(false)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.markUnresolved') || 'Mark unresolved' }}
|
||||
{{ t('admin.ops.errorDetail.markUnresolved') }}
|
||||
</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>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='overview' ? 'opacity-100' : 'opacity-70'" @click="activeTab='overview'">{{ t('admin.ops.errorDetail.tabOverview') }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='retries' ? 'opacity-100' : 'opacity-70'" @click="activeTab='retries'">{{ t('admin.ops.errorDetail.tabRetries') }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='request' ? 'opacity-100' : 'opacity-70'" @click="activeTab='request'">{{ t('admin.ops.errorDetail.tabRequest') }}</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :class="activeTab==='response' ? 'opacity-100' : 'opacity-70'" @click="activeTab='response'">{{ t('admin.ops.errorDetail.tabResponse') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab==='overview'">
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
<!-- Suggestion -->
|
||||
<div v-if="handlingSuggestion" 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>
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.suggestion') }}</h3>
|
||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words">
|
||||
{{ handlingSuggestion }}
|
||||
</div>
|
||||
@@ -110,41 +110,41 @@
|
||||
|
||||
<!-- 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>
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.classification') }}</h3>
|
||||
<div class="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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.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 class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.retryable') }}</div>
|
||||
<div class="mt-1 text-sm font-bold text-gray-900 dark:text-white">{{ (detail as any).is_retryable ? t('common.yes') : t('common.no') }}</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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.resolvedAt') }}</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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.resolvedBy') }}</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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.resolvedRetryId') }}</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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.classificationKeys.retryCount') }}</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200">{{ (detail as any).retry_count ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.group') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<el-tooltip v-if="detail.group_id" :content="'ID: ' + detail.group_id" placement="top">
|
||||
<el-tooltip v-if="detail.group_id" :content="t('admin.ops.errorLog.id') + ' ' + detail.group_id" placement="top">
|
||||
<span>{{ detail.group_name || detail.group_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
@@ -174,7 +174,7 @@
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.account') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<el-tooltip v-if="detail.account_id" :content="'ID: ' + detail.account_id" placement="top">
|
||||
<el-tooltip v-if="detail.account_id" :content="t('admin.ops.errorLog.id') + ' ' + detail.account_id" placement="top">
|
||||
<span>{{ detail.account_name || detail.account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
@@ -257,7 +257,7 @@
|
||||
</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>
|
||||
<span class="text-xs font-semibold text-amber-700 dark:text-amber-300">{{ t('admin.ops.errorDetail.notRetryable') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +268,7 @@
|
||||
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label>
|
||||
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" disabled />
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
pinned to original account_id
|
||||
{{ t('admin.ops.errorDetail.pinnedToOriginalAccountId') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
@@ -294,13 +294,13 @@
|
||||
|
||||
<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="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.status') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_status_code != null ? detail.upstream_status_code : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">message</div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.message') }}</div>
|
||||
<div class="mt-1 break-words text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_error_message || '—' }}
|
||||
</div>
|
||||
@@ -308,14 +308,14 @@
|
||||
</div>
|
||||
|
||||
<div v-if="detail.upstream_error_detail" class="mt-4">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">detail</div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.detail') }}</div>
|
||||
<pre
|
||||
class="mt-2 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(detail.upstream_error_detail) }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.upstream_errors" class="mt-5">
|
||||
<div class="mb-2 text-xs font-bold uppercase text-gray-400">upstream_errors</div>
|
||||
<div class="mb-2 text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstreamKeys.upstreamErrors') }}</div>
|
||||
|
||||
<div v-if="upstreamErrors.length" class="space-y-3">
|
||||
<div
|
||||
@@ -333,7 +333,7 @@
|
||||
type="button"
|
||||
class="rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-700 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
:disabled="retrying || !ev.upstream_request_body"
|
||||
:title="ev.upstream_request_body ? '' : 'missing upstream request body'"
|
||||
:title="ev.upstream_request_body ? '' : t('admin.ops.errorDetail.missingUpstreamRequestBody')"
|
||||
@click.stop="retryUpstreamEvent(idx)"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.retryUpstream') }} #{{ idx + 1 }}
|
||||
@@ -346,15 +346,15 @@
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-gray-400">account:</span>
|
||||
<el-tooltip v-if="ev.account_id" :content="'ID: ' + ev.account_id" placement="top">
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.account') }}:</span>
|
||||
<el-tooltip v-if="ev.account_id" :content="t('admin.ops.errorLog.id') + ' ' + ev.account_id" placement="top">
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-1">{{ ev.account_name || ev.account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="ml-1">—</span>
|
||||
</div>
|
||||
<div><span class="text-gray-400">status:</span> <span class="font-mono ml-1">{{ ev.upstream_status_code ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span> <span class="font-mono ml-1">{{ ev.upstream_status_code ?? '—' }}</span></div>
|
||||
<div class="sm:col-span-2 break-all">
|
||||
<span class="text-gray-400">request_id:</span> <span class="font-mono ml-1">{{ ev.upstream_request_id || '—' }}</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span> <span class="font-mono ml-1">{{ ev.upstream_request_id || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
|
||||
<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="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.retryHistory') }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="loadRetryHistory">{{ t('common.refresh') }}</button>
|
||||
</div>
|
||||
@@ -415,14 +415,14 @@
|
||||
<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>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.compareA') }}</div>
|
||||
<select v-model.number="compareA" class="input mt-2 w-full font-mono text-xs">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="a in retryHistory" :key="a.id" :value="a.id">#{{ a.id }} · {{ a.mode }} · {{ a.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.compareB') || 'Compare B' }}</div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.compareB') }}</div>
|
||||
<select v-model.number="compareB" class="input mt-2 w-full font-mono text-xs">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="b in retryHistory" :key="b.id" :value="b.id">#{{ b.id }} · {{ b.mode }} · {{ b.status }}</option>
|
||||
@@ -434,8 +434,8 @@
|
||||
<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">
|
||||
{{ t('admin.ops.errorDetail.retryMeta.http') }}: <span class="font-mono">{{ selectedA?.http_status_code ?? '—' }}</span> ·
|
||||
{{ t('admin.ops.errorDetail.retryMeta.used') }}: <span class="font-mono">
|
||||
<el-tooltip v-if="selectedA?.used_account_id" :content="'ID: ' + selectedA.used_account_id" placement="top">
|
||||
<span class="font-medium">{{ selectedA.used_account_name || selectedA.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
@@ -448,8 +448,8 @@
|
||||
<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">
|
||||
{{ t('admin.ops.errorDetail.retryMeta.http') }}: <span class="font-mono">{{ selectedB?.http_status_code ?? '—' }}</span> ·
|
||||
{{ t('admin.ops.errorDetail.retryMeta.used') }}: <span class="font-mono">
|
||||
<el-tooltip v-if="selectedB?.used_account_id" :content="'ID: ' + selectedB.used_account_id" placement="top">
|
||||
<span class="font-medium">{{ selectedB.used_account_name || selectedB.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
@@ -468,17 +468,17 @@
|
||||
<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">{{ t('admin.ops.errorDetail.retryMeta.success') }}:</span> <span class="font-mono">{{ a.success ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-400">{{ t('admin.ops.errorDetail.retryMeta.http') }}:</span> <span class="font-mono">{{ a.http_status_code ?? '—' }}</span></div>
|
||||
<div>
|
||||
<span class="text-gray-400">pinned:</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.retryMeta.pinned') }}:</span>
|
||||
<el-tooltip v-if="a.pinned_account_id" :content="'ID: ' + a.pinned_account_id" placement="top">
|
||||
<span class="font-mono ml-1">{{ a.pinned_account_name || a.pinned_account_id }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="font-mono ml-1">—</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">used:</span>
|
||||
<span class="text-gray-400">{{ t('admin.ops.errorDetail.retryMeta.used') }}:</span>
|
||||
<el-tooltip v-if="a.used_account_id" :content="'ID: ' + a.used_account_id" placement="top">
|
||||
<span class="font-mono ml-1">{{ a.used_account_name || a.used_account_id }}</span>
|
||||
</el-tooltip>
|
||||
@@ -502,7 +502,7 @@
|
||||
|
||||
<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>
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') }}</h3>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ responseTabHint }}
|
||||
</div>
|
||||
@@ -525,7 +525,7 @@
|
||||
<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>
|
||||
<span>{{ t('admin.ops.errorDetail.forceRetry') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,11 +578,11 @@ const compareB = ref<number | null>(null)
|
||||
const pinnedAccountIdInput = ref('')
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.errorId) return 'Error Detail'
|
||||
return `Error #${props.errorId}`
|
||||
if (!props.errorId) return t('admin.ops.errorDetail.title')
|
||||
return t('admin.ops.errorDetail.titleWithId', { id: String(props.errorId) })
|
||||
})
|
||||
|
||||
const emptyText = computed(() => 'No error selected.')
|
||||
const emptyText = computed(() => t('admin.ops.errorDetail.noErrorSelected'))
|
||||
|
||||
type UpstreamErrorEvent = {
|
||||
at_unix_ms?: number
|
||||
@@ -630,20 +630,20 @@ const handlingSuggestion = computed(() => {
|
||||
|
||||
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.suggestUpstreamResolved')
|
||||
}
|
||||
return t('admin.ops.errorDetail.suggestUpstream') || 'Upstream instability: consider checking upstream account status, switching accounts, or retrying.'
|
||||
return t('admin.ops.errorDetail.suggestUpstream')
|
||||
}
|
||||
if (owner === 'client' && phase === 'request') {
|
||||
return t('admin.ops.errorDetail.suggestRequest') || 'Client request validation error: contact customer to fix request parameters.'
|
||||
return t('admin.ops.errorDetail.suggestRequest')
|
||||
}
|
||||
if (owner === 'client' && phase === 'auth') {
|
||||
return t('admin.ops.errorDetail.suggestAuth') || 'Auth failed: verify API key/credentials.'
|
||||
return t('admin.ops.errorDetail.suggestAuth')
|
||||
}
|
||||
if (owner === 'platform') {
|
||||
return t('admin.ops.errorDetail.suggestPlatform') || 'Platform error: prioritize investigation and fix.'
|
||||
return t('admin.ops.errorDetail.suggestPlatform')
|
||||
}
|
||||
return t('admin.ops.errorDetail.suggestGeneric') || 'See details for more context.'
|
||||
return t('admin.ops.errorDetail.suggestGeneric')
|
||||
})
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
@@ -709,7 +709,7 @@ async function loadRetryHistory() {
|
||||
retryHistory.value = []
|
||||
compareA.value = null
|
||||
compareB.value = null
|
||||
appStore.showError(err?.message || 'Failed to load retry history')
|
||||
appStore.showError(err?.message || t('admin.ops.errorDetail.failedToLoadRetryHistory'))
|
||||
} finally {
|
||||
retryHistoryLoading.value = false
|
||||
}
|
||||
@@ -732,7 +732,7 @@ const responseTabHint = computed(() => {
|
||||
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'
|
||||
return t('admin.ops.errorDetail.responseHintFallback')
|
||||
})
|
||||
|
||||
async function markResolved(resolved: boolean) {
|
||||
@@ -745,9 +745,9 @@ async function markResolved(resolved: boolean) {
|
||||
await opsAPI.updateRequestErrorResolved(props.errorId, resolved)
|
||||
}
|
||||
await fetchDetail(props.errorId)
|
||||
appStore.showSuccess(resolved ? (t('admin.ops.errorDetails.resolved') || 'Resolved') : (t('admin.ops.errorDetails.unresolved') || 'Unresolved'))
|
||||
appStore.showSuccess(resolved ? t('admin.ops.errorDetails.resolved') : t('admin.ops.errorDetails.unresolved'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || 'Failed to update resolved status')
|
||||
appStore.showError(err?.message || t('admin.ops.errorDetail.failedToUpdateResolvedStatus'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@ 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.'
|
||||
return t('admin.ops.errorDetail.forceRetryHint')
|
||||
}
|
||||
if (mode === 'upstream') {
|
||||
return t('admin.ops.errorDetail.confirmRetryMessage')
|
||||
@@ -781,7 +781,7 @@ async function runConfirmedRetry() {
|
||||
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')
|
||||
appStore.showError(t('admin.ops.errorDetail.forceRetryNeedAck'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -799,7 +799,7 @@ async function runConfirmedRetry() {
|
||||
if (mode === 'client') {
|
||||
res = await opsAPI.retryRequestErrorClient(props.errorId)
|
||||
} else {
|
||||
throw new Error('Unsupported retry mode')
|
||||
throw new Error(t('admin.ops.errorDetail.unsupportedRetryMode'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const q = ref('')
|
||||
const statusCode = ref<number | null>(null)
|
||||
const statusCode = ref<number | 'other' | null>(null)
|
||||
const phase = ref<string>('')
|
||||
const errorOwner = ref<string>('')
|
||||
const resolvedStatus = ref<string>('unresolved')
|
||||
@@ -44,16 +44,17 @@ 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: 'provider' },
|
||||
{ value: 'client', label: 'client' },
|
||||
{ value: 'platform', label: 'platform' }
|
||||
{ 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' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -76,12 +77,12 @@ const viewModeSelectOptions = computed(() => {
|
||||
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: '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
|
||||
})
|
||||
@@ -107,7 +108,8 @@ 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 === 'number') params.status_codes = String(statusCode.value)
|
||||
if (statusCode.value === 'other') 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
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<!-- Group -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.group_id" :content="'ID: ' + log.group_id" placement="top" :show-after="500">
|
||||
<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>
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
<!-- Account -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.account_id" :content="'ID: ' + log.account_id" placement="top" :show-after="500">
|
||||
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.id') + ' ' + 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>
|
||||
@@ -195,8 +195,8 @@ function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
|
||||
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 || '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 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 {
|
||||
@@ -238,9 +238,9 @@ function formatSmartMessage(msg: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.includes('context deadline exceeded')) return 'context deadline exceeded'
|
||||
if (msg.includes('connection refused')) return 'connection refused'
|
||||
if (msg.toLowerCase().includes('rate limit')) return 'rate limit'
|
||||
if (msg.includes('context deadline exceeded')) return t('admin.ops.errorLog.commonErrors.contextDeadlineExceeded')
|
||||
if (msg.includes('connection refused')) return t('admin.ops.errorLog.commonErrors.connectionRefused')
|
||||
if (msg.toLowerCase().includes('rate limit')) return t('admin.ops.errorLog.commonErrors.rateLimit')
|
||||
|
||||
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
|
||||
}
|
||||
|
||||
@@ -50,22 +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 之间')
|
||||
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'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,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"
|
||||
@@ -344,13 +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">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"
|
||||
@@ -359,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"
|
||||
@@ -373,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"
|
||||
@@ -387,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>
|
||||
|
||||
@@ -157,16 +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之间')
|
||||
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 }
|
||||
@@ -472,15 +472,15 @@ 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">忽略 count_tokens 错误</label>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreCountTokensErrors') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
启用后,count_tokens 请求的错误将不会写入错误日志
|
||||
{{ t('admin.ops.settings.ignoreCountTokensErrorsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_count_tokens_errors" />
|
||||
@@ -488,9 +488,9 @@ async function saveAllSettings() {
|
||||
|
||||
<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.ignoreContextCanceled') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志
|
||||
{{ t('admin.ops.settings.ignoreContextCanceledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_context_canceled" />
|
||||
@@ -498,37 +498,37 @@ async function saveAllSettings() {
|
||||
|
||||
<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.ignoreNoAvailableAccounts') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)
|
||||
{{ 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>
|
||||
|
||||
Reference in New Issue
Block a user