merge: 合并上游 upstream/main 分支

解决冲突:
- usage_log_repo.go: 保留 groupID 和 stream 参数,同时合并上游的
  account_rate_multiplier 逻辑

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
longgexx
2026-01-15 13:09:26 +08:00
102 changed files with 6554 additions and 1547 deletions

View File

@@ -17,6 +17,47 @@ export interface OpsRequestOptions {
export interface OpsRetryRequest {
mode: OpsRetryMode
pinned_account_id?: number
force?: boolean
}
export interface OpsRetryAttempt {
id: number
created_at: string
requested_by_user_id: number
source_error_id: number
mode: string
pinned_account_id?: number | null
pinned_account_name?: string
status: string
started_at?: string | null
finished_at?: string | null
duration_ms?: number | null
success?: boolean | null
http_status_code?: number | null
upstream_request_id?: string | null
used_account_id?: number | null
used_account_name?: string
response_preview?: string | null
response_truncated?: boolean | null
result_request_id?: string | null
result_error_id?: number | null
error_message?: string | null
}
export type OpsUpstreamErrorEvent = {
at_unix_ms?: number
platform?: string
account_id?: number
account_name?: string
upstream_status_code?: number
upstream_request_id?: string
upstream_request_body?: string
kind?: string
message?: string
detail?: string
}
export interface OpsRetryResult {
@@ -626,8 +667,6 @@ export type MetricType =
| 'success_rate'
| 'error_rate'
| 'upstream_error_rate'
| 'p95_latency_ms'
| 'p99_latency_ms'
| 'cpu_usage_percent'
| 'memory_usage_percent'
| 'concurrency_queue_depth'
@@ -663,7 +702,7 @@ export interface AlertEvent {
id: number
rule_id: number
severity: OpsSeverity | string
status: 'firing' | 'resolved' | string
status: 'firing' | 'resolved' | 'manual_resolved' | string
title?: string
description?: string
metric_value?: number
@@ -701,10 +740,9 @@ export interface EmailNotificationConfig {
}
export interface OpsMetricThresholds {
sla_percent_min?: number | null // SLA低于此值变红
latency_p99_ms_max?: number | null // 延迟P99高于此值变红
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
sla_percent_min?: number | null // SLA低于此值变红
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红
}
@@ -735,6 +773,8 @@ export interface OpsAdvancedSettings {
data_retention: OpsDataRetentionSettings
aggregation: OpsAggregationSettings
ignore_count_tokens_errors: boolean
ignore_context_canceled: boolean
ignore_no_available_accounts: boolean
auto_refresh_enabled: boolean
auto_refresh_interval_seconds: number
}
@@ -754,21 +794,37 @@ export interface OpsAggregationSettings {
export interface OpsErrorLog {
id: number
created_at: string
// Standardized classification
phase: OpsPhase
type: string
error_owner: 'client' | 'provider' | 'platform' | string
error_source: 'client_request' | 'upstream_http' | 'gateway' | string
severity: OpsSeverity
status_code: number
platform: string
model: string
latency_ms?: number | null
is_retryable: boolean
retry_count: number
resolved: boolean
resolved_at?: string | null
resolved_by_user_id?: number | null
resolved_retry_id?: number | null
client_request_id: string
request_id: string
message: string
user_id?: number | null
user_email: string
api_key_id?: number | null
account_id?: number | null
account_name: string
group_id?: number | null
group_name: string
client_ip?: string | null
request_path?: string
@@ -890,7 +946,9 @@ export async function getErrorDistribution(
return data
}
export async function listErrorLogs(params: {
export type OpsErrorListView = 'errors' | 'excluded' | 'all'
export type OpsErrorListQueryParams = {
page?: number
page_size?: number
time_range?: string
@@ -899,10 +957,20 @@ export async function listErrorLogs(params: {
platform?: string
group_id?: number | null
account_id?: number | null
phase?: string
error_owner?: string
error_source?: string
resolved?: string
view?: OpsErrorListView
q?: string
status_codes?: string
}): Promise<OpsErrorLogsResponse> {
status_codes_other?: string
}
// Legacy unified endpoints
export async function listErrorLogs(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/errors', { params })
return data
}
@@ -917,6 +985,70 @@ export async function retryErrorRequest(id: number, req: OpsRetryRequest): Promi
return data
}
export async function listRetryAttempts(errorId: number, limit = 50): Promise<OpsRetryAttempt[]> {
const { data } = await apiClient.get<OpsRetryAttempt[]>(`/admin/ops/errors/${errorId}/retries`, { params: { limit } })
return data
}
export async function updateErrorResolved(errorId: number, resolved: boolean): Promise<void> {
await apiClient.put(`/admin/ops/errors/${errorId}/resolve`, { resolved })
}
// New split endpoints
export async function listRequestErrors(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/request-errors', { params })
return data
}
export async function listUpstreamErrors(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/upstream-errors', { params })
return data
}
export async function getRequestErrorDetail(id: number): Promise<OpsErrorDetail> {
const { data } = await apiClient.get<OpsErrorDetail>(`/admin/ops/request-errors/${id}`)
return data
}
export async function getUpstreamErrorDetail(id: number): Promise<OpsErrorDetail> {
const { data } = await apiClient.get<OpsErrorDetail>(`/admin/ops/upstream-errors/${id}`)
return data
}
export async function retryRequestErrorClient(id: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/request-errors/${id}/retry-client`, {})
return data
}
export async function retryRequestErrorUpstreamEvent(id: number, idx: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/request-errors/${id}/upstream-errors/${idx}/retry`, {})
return data
}
export async function retryUpstreamError(id: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/upstream-errors/${id}/retry`, {})
return data
}
export async function updateRequestErrorResolved(errorId: number, resolved: boolean): Promise<void> {
await apiClient.put(`/admin/ops/request-errors/${errorId}/resolve`, { resolved })
}
export async function updateUpstreamErrorResolved(errorId: number, resolved: boolean): Promise<void> {
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> {
const { data } = await apiClient.get<OpsRequestDetailsResponse>('/admin/ops/requests', { params })
return data
@@ -942,11 +1074,45 @@ export async function deleteAlertRule(id: number): Promise<void> {
await apiClient.delete(`/admin/ops/alert-rules/${id}`)
}
export async function listAlertEvents(limit = 100): Promise<AlertEvent[]> {
const { data } = await apiClient.get<AlertEvent[]>('/admin/ops/alert-events', { params: { limit } })
export interface AlertEventsQuery {
limit?: number
status?: string
severity?: string
email_sent?: boolean
time_range?: string
start_time?: string
end_time?: string
before_fired_at?: string
before_id?: number
platform?: string
group_id?: number
}
export async function listAlertEvents(params: AlertEventsQuery = {}): Promise<AlertEvent[]> {
const { data } = await apiClient.get<AlertEvent[]>('/admin/ops/alert-events', { params })
return data
}
export async function getAlertEvent(id: number): Promise<AlertEvent> {
const { data } = await apiClient.get<AlertEvent>(`/admin/ops/alert-events/${id}`)
return data
}
export async function updateAlertEventStatus(id: number, status: 'resolved' | 'manual_resolved'): Promise<void> {
await apiClient.put(`/admin/ops/alert-events/${id}/status`, { status })
}
export async function createAlertSilence(payload: {
rule_id: number
platform: string
group_id?: number | null
region?: string | null
until: string
reason?: string
}): Promise<void> {
await apiClient.post('/admin/ops/alert-silences', payload)
}
// Email notification config
export async function getEmailNotificationConfig(): Promise<EmailNotificationConfig> {
const { data } = await apiClient.get<EmailNotificationConfig>('/admin/ops/email-notification/config')
@@ -1001,15 +1167,35 @@ export const opsAPI = {
getAccountAvailabilityStats,
getRealtimeTrafficSummary,
subscribeQPS,
// Legacy unified endpoints
listErrorLogs,
getErrorLogDetail,
retryErrorRequest,
listRetryAttempts,
updateErrorResolved,
// New split endpoints
listRequestErrors,
listUpstreamErrors,
getRequestErrorDetail,
getUpstreamErrorDetail,
retryRequestErrorClient,
retryRequestErrorUpstreamEvent,
retryUpstreamError,
updateRequestErrorResolved,
updateUpstreamErrorResolved,
listRequestErrorUpstreamErrors,
listRequestDetails,
listAlertRules,
createAlertRule,
updateAlertRule,
deleteAlertRule,
listAlertEvents,
getAlertEvent,
updateAlertEventStatus,
createAlertSilence,
getEmailNotificationConfig,
updateEmailNotificationConfig,
getAlertRuntimeSettings,

View File

@@ -4,7 +4,13 @@
*/
import { apiClient } from '../client'
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types'
import type {
Proxy,
ProxyAccountSummary,
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse
} from '@/types'
/**
* List all proxies with pagination
@@ -160,8 +166,8 @@ export async function getStats(id: number): Promise<{
* @param id - Proxy ID
* @returns List of accounts using the proxy
*/
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`)
export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
return data
}
@@ -189,6 +195,17 @@ export async function batchCreate(
return data
}
export async function batchDelete(ids: number[]): Promise<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}> {
const { data } = await apiClient.post<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}>('/admin/proxies/batch-delete', { ids })
return data
}
export const proxiesAPI = {
list,
getAll,
@@ -201,7 +218,8 @@ export const proxiesAPI = {
testProxy,
getStats,
getProxyAccounts,
batchCreate
batchCreate,
batchDelete
}
export default proxiesAPI

View File

@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
total_tokens: number
total_cost: number
total_actual_cost: number
total_account_cost?: number
average_duration_ms: number
}

View File

@@ -73,11 +73,12 @@
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500"
>({{ t('admin.accounts.stats.standardCost') }}: ${{
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})</span
>
}})
</span>
</p>
</div>
@@ -121,12 +122,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used
})
}}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p>
</div>
@@ -189,13 +193,17 @@
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
@@ -240,13 +248,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
@@ -291,13 +303,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
@@ -397,13 +413,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayCost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('admin.accounts.stats.cost') + ' (USD)',
data: stats.value.history.map((h) => h.cost),
label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
},
title: {
display: true,
text: t('admin.accounts.stats.cost') + ' (USD)',
text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6',
font: {
size: 11

View File

@@ -32,15 +32,20 @@
formatTokens(stats.tokens)
}}</span>
</div>
<!-- Cost -->
<!-- Cost (Account) -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.cost') }}:</span
>
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost)
}}</span>
</div>
<!-- Cost (User/API Key) -->
<div v-if="stats.user_cost != null" class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatCurrency(stats.user_cost)
}}</span>
</div>
</div>
<!-- No data -->

View File

@@ -459,7 +459,7 @@
</div>
<!-- Concurrency & Priority -->
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-3">
<div>
<div class="mb-3 flex items-center justify-between">
<label
@@ -516,6 +516,36 @@
aria-labelledby="bulk-edit-priority-label"
/>
</div>
<div>
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-rate-multiplier-label"
class="input-label mb-0"
for="bulk-edit-rate-multiplier-enabled"
>
{{ t('admin.accounts.billingRateMultiplier') }}
</label>
<input
v-model="enableRateMultiplier"
id="bulk-edit-rate-multiplier-enabled"
type="checkbox"
aria-controls="bulk-edit-rate-multiplier"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<input
v-model.number="rateMultiplier"
id="bulk-edit-rate-multiplier"
type="number"
min="0"
step="0.01"
:disabled="!enableRateMultiplier"
class="input"
:class="!enableRateMultiplier && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-rate-multiplier-label"
/>
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div>
<!-- Status -->
@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
const enableProxy = ref(false)
const enableConcurrency = ref(false)
const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
const proxyId = ref<number | null>(null)
const concurrency = ref(1)
const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([])
@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.priority = priority.value
}
if (enableRateMultiplier.value) {
updates.rate_multiplier = rateMultiplier.value
}
if (enableStatus.value) {
updates.status = status.value
}
@@ -923,6 +959,7 @@ const handleSubmit = async () => {
enableProxy.value ||
enableConcurrency.value ||
enablePriority.value ||
enableRateMultiplier.value ||
enableStatus.value ||
enableGroups.value
@@ -977,6 +1014,7 @@ watch(
enableProxy.value = false
enableConcurrency.value = false
enablePriority.value = false
enableRateMultiplier.value = false
enableStatus.value = false
enableGroups.value = false
@@ -991,6 +1029,7 @@ watch(
proxyId.value = null
concurrency.value = 1
priority.value = 1
rateMultiplier.value = 1
status.value = 'active'
groupIds.value = []
}

View File

@@ -1196,7 +1196,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
@@ -1212,6 +1212,11 @@
/>
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
@@ -1832,6 +1837,7 @@ const form = reactive({
proxy_id: null as number | null,
concurrency: 10,
priority: 1,
rate_multiplier: 1,
group_ids: [] as number[],
expires_at: null as number | null
})
@@ -2119,6 +2125,7 @@ const resetForm = () => {
form.proxy_id = null
form.concurrency = 10
form.priority = 1
form.rate_multiplier = 1
form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based'
@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value

View File

@@ -549,7 +549,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
@@ -564,6 +564,11 @@
data-tour="account-form-priority"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
@@ -807,6 +812,7 @@ const form = reactive({
proxy_id: null as number | null,
concurrency: 1,
priority: 1,
rate_multiplier: 1,
status: 'active' as 'active' | 'inactive',
group_ids: [] as number[],
expires_at: null as number | null
@@ -834,6 +840,7 @@ watch(
form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency
form.priority = newAccount.priority
form.rate_multiplier = newAccount.rate_multiplier ?? 1
form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null

View File

@@ -15,7 +15,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div>
</div>
@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return t.toString()
})
const formatCost = computed(() => {
const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2)
})
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script>

View File

@@ -61,11 +61,12 @@
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500"
>({{ t('admin.accounts.stats.standardCost') }}: ${{
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})</span
>
}})
</span>
</p>
</div>
@@ -108,12 +109,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used
})
}}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p>
</div>
@@ -164,13 +168,17 @@
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
@@ -210,13 +218,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
@@ -260,13 +272,17 @@
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div>
</div>
</div>
@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('admin.accounts.stats.cost') + ' (USD)',
data: stats.value.history.map((h) => h.cost),
label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
},
title: {
display: true,
text: t('admin.accounts.stats.cost') + ' (USD)',
text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6',
font: {
size: 11

View File

@@ -27,9 +27,18 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
<p class="text-xs text-gray-400">
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<p class="text-xl font-bold text-green-600">
${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div>

View File

@@ -81,18 +81,23 @@
</template>
<template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
</div>
</div>
</template>
@@ -202,14 +207,24 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>

View File

@@ -25,7 +25,7 @@
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" />

View File

@@ -1,7 +1,68 @@
<template>
<div class="md:hidden space-y-3">
<template v-if="loading">
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
<div class="space-y-3">
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</div>
</template>
<template v-else-if="!data || data.length === 0">
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
<slot name="empty">
<div class="flex flex-col items-center">
<Icon
name="inbox"
size="xl"
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
/>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ t('empty.noData') }}
</p>
</div>
</slot>
</div>
</template>
<template v-else>
<div
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="space-y-3">
<div
v-for="column in columns.filter(c => c.key !== 'actions')"
:key="column.key"
class="flex items-start justify-between gap-4"
>
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
{{ column.label }}
</span>
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
</div>
</div>
</div>
</template>
</div>
<div
ref="tableWrapperRef"
class="table-wrapper"
class="table-wrapper hidden md:block"
:class="{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
@@ -22,29 +83,36 @@
]"
@click="column.sortable && handleSort(column.key)"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg
v-if="sortKey === column.key"
class="h-4 w-4"
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
<slot
:name="`header-${column.key}`"
:column="column"
:sort-key="sortKey"
:sort-order="sortOrder"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg
v-if="sortKey === column.key"
class="h-4 w-4"
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
</slot>
</th>
</tr>
</thead>
@@ -277,7 +345,10 @@ const sortedData = computed(() => {
})
})
// 检查第一列是否为勾选列
const hasActionsColumn = computed(() => {
return props.columns.some(column => column.key === 'actions')
})
const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select'
})

View File

@@ -129,6 +129,8 @@ export default {
all: 'All',
none: 'None',
noData: 'No data',
expand: 'Expand',
collapse: 'Collapse',
success: 'Success',
error: 'Error',
critical: 'Critical',
@@ -150,12 +152,13 @@ export default {
invalidEmail: 'Please enter a valid email address',
optional: 'optional',
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available',
unknownError: 'Unknown error occurred',
saving: 'Saving...',
selectedCount: '({count} selected)', refresh: 'Refresh',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available',
unknownError: 'Unknown error occurred',
saving: 'Saving...',
selectedCount: '({count} selected)',
refresh: 'Refresh',
settings: 'Settings',
notAvailable: 'N/A',
now: 'Now',
@@ -429,6 +432,9 @@ export default {
totalCost: 'Total Cost',
standardCost: 'Standard',
actualCost: 'Actual',
userBilled: 'User billed',
accountBilled: 'Account billed',
accountMultiplier: 'Account rate',
avgDuration: 'Avg Duration',
inSelectedRange: 'in selected range',
perRequest: 'per request',
@@ -1059,6 +1065,7 @@ export default {
concurrencyStatus: 'Concurrency',
notes: 'Notes',
priority: 'Priority',
billingRateMultiplier: 'Billing Rate',
weight: 'Weight',
status: 'Status',
schedulable: 'Schedulable',
@@ -1226,6 +1233,8 @@ export default {
concurrency: 'Concurrency',
priority: 'Priority',
priorityHint: 'Lower value accounts are used first',
billingRateMultiplier: 'Billing Rate Multiplier',
billingRateMultiplierHint: '>=0, 0 means free. Affects account billing only',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Lower value means higher priority',
@@ -1627,11 +1636,29 @@ export default {
address: 'Address',
status: 'Status',
accounts: 'Accounts',
latency: 'Latency',
actions: 'Actions'
},
testConnection: 'Test Connection',
batchTest: 'Test All Proxies',
testFailed: 'Failed',
latencyFailed: 'Connection failed',
batchTestEmpty: 'No proxies available for testing',
batchTestDone: 'Batch test completed for {count} proxies',
batchTestFailed: 'Batch test failed',
batchDeleteAction: 'Delete',
batchDelete: 'Batch delete',
batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.',
batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}',
batchDeleteSkipped: 'Skipped {skipped} proxies',
batchDeleteFailed: 'Batch delete failed',
deleteBlockedInUse: 'This proxy is in use and cannot be deleted',
accountsTitle: 'Accounts using this IP',
accountsEmpty: 'No accounts are using this proxy',
accountsFailed: 'Failed to load accounts list',
accountName: 'Account',
accountPlatform: 'Platform',
accountNotes: 'Notes',
name: 'Name',
protocol: 'Protocol',
host: 'Host',
@@ -1858,10 +1885,8 @@ export default {
noSystemMetrics: 'No system metrics collected yet.',
collectedAt: 'Collected at:',
window: 'window',
cpu: 'CPU',
memory: 'Memory',
db: 'DB',
redis: 'Redis',
goroutines: 'Goroutines',
jobs: 'Jobs',
jobsHelp: 'Click “Details” to view job heartbeats and recent errors',
@@ -1887,7 +1912,7 @@ export default {
totalRequests: 'Total Requests',
avgQps: 'Avg QPS',
avgTps: 'Avg TPS',
avgLatency: 'Avg Latency',
avgLatency: 'Avg Request Duration',
avgTtft: 'Avg TTFT',
exceptions: 'Exceptions',
requestErrors: 'Request Errors',
@@ -1899,7 +1924,7 @@ export default {
errors: 'Errors',
errorRate: 'error_rate:',
upstreamRate: 'upstream_rate:',
latencyDuration: 'Latency (duration_ms)',
latencyDuration: 'Request Duration (ms)',
ttftLabel: 'TTFT (first_token_ms)',
p50: 'p50:',
p90: 'p90:',
@@ -1907,7 +1932,6 @@ export default {
p99: 'p99:',
avg: 'avg:',
max: 'max:',
qps: 'QPS',
requests: 'Requests',
requestsTitle: 'Requests',
upstream: 'Upstream',
@@ -1919,7 +1943,7 @@ export default {
failedToLoadData: 'Failed to load ops data.',
failedToLoadOverview: 'Failed to load overview',
failedToLoadThroughputTrend: 'Failed to load throughput trend',
failedToLoadLatencyHistogram: 'Failed to load latency histogram',
failedToLoadLatencyHistogram: 'Failed to load request duration histogram',
failedToLoadErrorTrend: 'Failed to load error trend',
failedToLoadErrorDistribution: 'Failed to load error distribution',
failedToLoadErrorDetail: 'Failed to load error detail',
@@ -1927,7 +1951,7 @@ export default {
tpsK: 'TPS (K)',
top: 'Top:',
throughputTrend: 'Throughput Trend',
latencyHistogram: 'Latency Histogram',
latencyHistogram: 'Request Duration Histogram',
errorTrend: 'Error Trend',
errorDistribution: 'Error Distribution',
// Health Score & Diagnosis
@@ -1942,7 +1966,9 @@ export default {
'30m': 'Last 30 minutes',
'1h': 'Last 1 hour',
'6h': 'Last 6 hours',
'24h': 'Last 24 hours'
'24h': 'Last 24 hours',
'7d': 'Last 7 days',
'30d': 'Last 30 days'
},
fullscreen: {
enter: 'Enter Fullscreen'
@@ -1971,14 +1997,7 @@ export default {
memoryHigh: 'Memory usage elevated ({usage}%)',
memoryHighImpact: 'Memory pressure is high, needs attention',
memoryHighAction: 'Monitor memory trends, check for memory leaks',
// Latency diagnostics
latencyCritical: 'Response latency critically high ({latency}ms)',
latencyCriticalImpact: 'User experience extremely poor, many requests timing out',
latencyCriticalAction: 'Check slow queries, database indexes, network latency, and upstream services',
latencyHigh: 'Response latency elevated ({latency}ms)',
latencyHighImpact: 'User experience degraded, needs optimization',
latencyHighAction: 'Analyze slow request logs, optimize database queries and business logic',
ttftHigh: 'Time to first byte elevated ({ttft}ms)',
ttftHigh: 'Time to first token elevated ({ttft}ms)',
ttftHighImpact: 'User perceived latency increased',
ttftHighAction: 'Optimize request processing flow, reduce pre-processing time',
// Error rate diagnostics
@@ -2014,27 +2033,106 @@ 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',
platform: 'Platform',
model: 'Model',
group: 'Group',
user: 'User',
userId: 'User ID',
account: 'Account',
accountId: 'Account ID',
status: 'Status',
message: 'Message',
latency: 'Latency',
latency: 'Request Duration',
action: 'Action',
noErrors: 'No errors in this window.',
grp: 'GRP:',
acc: 'ACC:',
details: 'Details',
phase: 'Phase'
phase: 'Phase',
id: 'ID:',
typeUpstream: 'Upstream',
typeRequest: 'Request',
typeAuth: 'Auth',
typeRouting: 'Routing',
typeInternal: 'Internal'
},
// Error Details Modal
errorDetails: {
upstreamErrors: 'Upstream Errors',
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'
},
source: {
upstream_http: 'Upstream HTTP'
},
upstreamKeys: {
status: 'Status',
message: 'Message',
detail: 'Detail',
upstreamErrors: 'Upstream Errors'
},
upstreamEvent: {
account: 'Account',
status: 'Status',
requestId: 'Request ID'
},
responsePreview: {
expand: 'Response (click to expand)',
collapse: 'Response (click to collapse)'
},
retryMeta: {
used: 'Used',
success: 'Success',
pinned: 'Pinned'
},
loading: 'Loading…',
requestId: 'Request ID',
time: 'Time',
@@ -2044,8 +2142,10 @@ export default {
basicInfo: 'Basic Info',
platform: 'Platform',
model: 'Model',
latency: 'Latency',
ttft: 'TTFT',
group: 'Group',
user: 'User',
account: 'Account',
latency: 'Request Duration',
businessLimited: 'Business Limited',
requestPath: 'Request Path',
timings: 'Timings',
@@ -2053,6 +2153,8 @@ export default {
routing: 'Routing',
upstream: 'Upstream',
response: 'Response',
classification: 'Classification',
notRetryable: 'Not recommended to retry',
retry: 'Retry',
retryClient: 'Retry (Client)',
retryUpstream: 'Retry (Upstream pinned)',
@@ -2064,7 +2166,6 @@ export default {
confirmRetry: 'Confirm Retry',
retrySuccess: 'Retry succeeded',
retryFailed: 'Retry failed',
na: 'N/A',
retryHint: 'Retry will resend the request with the same parameters',
retryClientHint: 'Use client retry (no account pinning)',
retryUpstreamHint: 'Use upstream pinned retry (pin to the error account)',
@@ -2072,8 +2173,33 @@ 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'
confirmRetryHint: 'Will resend with the same request parameters',
forceRetry: 'I understand and want to force retry',
forceRetryHint: 'This error usually cannot be fixed by retry; check to proceed',
forceRetryNeedAck: 'Please check to force retry',
markResolved: 'Mark resolved',
markUnresolved: 'Mark unresolved',
viewRetries: 'Retry history',
retryHistory: 'Retry History',
tabOverview: 'Overview',
tabRetries: 'Retries',
tabRequest: 'Request',
tabResponse: 'Response',
responseBody: 'Response',
compareA: 'Compare A',
compareB: 'Compare B',
retrySummary: 'Retry Summary',
responseHintSucceeded: 'Showing succeeded retry response_preview (#{id})',
responseHintFallback: 'No succeeded retry found; showing stored error_body',
suggestion: 'Suggestion',
suggestUpstreamResolved: '✓ Upstream error resolved by retry; no action needed',
suggestUpstream: 'Upstream instability: check account status, consider switching accounts, or retry',
suggestRequest: 'Client request error: ask customer to fix request parameters',
suggestAuth: 'Auth failed: verify API key/credentials',
suggestPlatform: 'Platform error: prioritize investigation and fix',
suggestGeneric: 'See details for more context'
},
requestDetails: {
title: 'Request Details',
@@ -2109,13 +2235,46 @@ export default {
loading: 'Loading...',
empty: 'No alert events',
loadFailed: 'Failed to load alert events',
status: {
firing: 'FIRING',
resolved: 'RESOLVED',
manualResolved: 'MANUAL RESOLVED'
},
detail: {
title: 'Alert Detail',
loading: 'Loading detail...',
empty: 'No detail',
loadFailed: 'Failed to load alert detail',
manualResolve: 'Mark as Resolved',
manualResolvedSuccess: 'Marked as manually resolved',
manualResolvedFailed: 'Failed to mark as manually resolved',
silence: 'Ignore Alert',
silenceSuccess: 'Alert silenced',
silenceFailed: 'Failed to silence alert',
viewRule: 'View Rule',
viewLogs: 'View Logs',
firedAt: 'Fired At',
resolvedAt: 'Resolved At',
ruleId: 'Rule ID',
dimensions: 'Dimensions',
historyTitle: 'History',
historyHint: 'Recent events with same rule + dimensions',
historyLoading: 'Loading history...',
historyEmpty: 'No history'
},
table: {
time: 'Time',
status: 'Status',
severity: 'Severity',
platform: 'Platform',
ruleId: 'Rule ID',
title: 'Title',
duration: 'Duration',
metric: 'Metric / Threshold',
email: 'Email Sent'
dimensions: 'Dimensions',
email: 'Email Sent',
emailSent: 'Sent',
emailIgnored: 'Ignored'
}
},
alertRules: {
@@ -2229,7 +2388,6 @@ export default {
title: 'Alert Silencing (Maintenance Mode)',
enabled: 'Enable silencing',
globalUntil: 'Silence until (RFC3339)',
untilPlaceholder: '2026-01-05T00:00:00Z',
untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).',
reason: 'Reason',
reasonPlaceholder: 'e.g., planned maintenance',
@@ -2269,7 +2427,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: {
@@ -2334,8 +2496,6 @@ export default {
metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red',
slaMinPercent: 'SLA Minimum Percentage',
slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)',
latencyP99MaxMs: 'Latency P99 Maximum (ms)',
latencyP99MaxMsHint: 'Latency P99 above this value will be displayed in red (default: 2000ms)',
ttftP99MaxMs: 'TTFT P99 Maximum (ms)',
ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)',
requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)',
@@ -2354,9 +2514,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: {
@@ -2394,7 +2573,7 @@ export default {
tooltips: {
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
latencyHistogram: 'Latency distribution (duration_ms) for successful requests.',
latencyHistogram: 'Request duration distribution (ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.',
goroutines:
@@ -2409,7 +2588,7 @@ export default {
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).',
latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.',
latency: 'Request duration statistics, including p50, p90, p95, p99 percentiles.',
ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.',
health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
},

View File

@@ -126,6 +126,8 @@ export default {
all: '全部',
none: '无',
noData: '暂无数据',
expand: '展开',
collapse: '收起',
success: '成功',
error: '错误',
critical: '严重',
@@ -426,6 +428,9 @@ export default {
totalCost: '总消费',
standardCost: '标准',
actualCost: '实际',
userBilled: '用户扣费',
accountBilled: '账号计费',
accountMultiplier: '账号倍率',
avgDuration: '平均耗时',
inSelectedRange: '所选范围内',
perRequest: '每次请求',
@@ -1109,6 +1114,7 @@ export default {
concurrencyStatus: '并发',
notes: '备注',
priority: '优先级',
billingRateMultiplier: '账号倍率',
weight: '权重',
status: '状态',
schedulable: '调度',
@@ -1360,6 +1366,8 @@ export default {
concurrency: '并发数',
priority: '优先级',
priorityHint: '优先级越小的账号优先使用',
billingRateMultiplier: '账号计费倍率',
billingRateMultiplierHint: '>=00 表示该账号计费为 0仅影响账号计费口径',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越小优先级越高',
@@ -1713,6 +1721,7 @@ export default {
address: '地址',
status: '状态',
accounts: '账号数',
latency: '延迟',
actions: '操作',
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
@@ -1749,11 +1758,32 @@ export default {
enterProxyName: '请输入代理名称',
optionalAuth: '可选认证信息',
leaveEmptyToKeep: '留空保持不变',
form: {
hostPlaceholder: '请输入主机地址',
portPlaceholder: '请输入端口'
},
noProxiesYet: '暂无代理',
createFirstProxy: '添加您的第一个代理以开始使用。',
testConnection: '测试连接',
batchTest: '批量测试',
testFailed: '失败',
latencyFailed: '链接失败',
batchTestEmpty: '暂无可测试的代理',
batchTestDone: '批量测试完成,共测试 {count} 个代理',
batchTestFailed: '批量测试失败',
batchDeleteAction: '删除',
batchDelete: '批量删除',
batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。',
batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个',
batchDeleteSkipped: '已跳过 {skipped} 个代理',
batchDeleteFailed: '批量删除失败',
deleteBlockedInUse: '该代理已有账号使用,无法删除',
accountsTitle: '使用该IP的账号',
accountsEmpty: '暂无账号使用此代理',
accountsFailed: '获取账号列表失败',
accountName: '账号名称',
accountPlatform: '所属平台',
accountNotes: '备注',
// Batch import
standardAdd: '标准添加',
batchAdd: '快捷添加',
@@ -2003,10 +2033,8 @@ export default {
noSystemMetrics: '尚未收集系统指标。',
collectedAt: '采集时间:',
window: '窗口',
cpu: 'CPU',
memory: '内存',
db: '数据库',
redis: 'Redis',
goroutines: '协程',
jobs: '后台任务',
jobsHelp: '点击“明细”查看任务心跳与报错信息',
@@ -2032,7 +2060,7 @@ export default {
totalRequests: '总请求',
avgQps: '平均 QPS',
avgTps: '平均 TPS',
avgLatency: '平均延迟',
avgLatency: '平均请求时长',
avgTtft: '平均首字延迟',
exceptions: '异常数',
requestErrors: '请求错误',
@@ -2044,7 +2072,7 @@ export default {
errors: '错误',
errorRate: '错误率:',
upstreamRate: '上游错误率:',
latencyDuration: '延迟(毫秒)',
latencyDuration: '请求时长(毫秒)',
ttftLabel: '首字延迟(毫秒)',
p50: 'p50',
p90: 'p90',
@@ -2052,7 +2080,6 @@ export default {
p99: 'p99',
avg: 'avg',
max: 'max',
qps: 'QPS',
requests: '请求数',
requestsTitle: '请求',
upstream: '上游',
@@ -2064,7 +2091,7 @@ export default {
failedToLoadData: '加载运维数据失败',
failedToLoadOverview: '加载概览数据失败',
failedToLoadThroughputTrend: '加载吞吐趋势失败',
failedToLoadLatencyHistogram: '加载延迟分布失败',
failedToLoadLatencyHistogram: '加载请求时长分布失败',
failedToLoadErrorTrend: '加载错误趋势失败',
failedToLoadErrorDistribution: '加载错误分布失败',
failedToLoadErrorDetail: '加载错误详情失败',
@@ -2072,7 +2099,7 @@ export default {
tpsK: 'TPS',
top: '最高:',
throughputTrend: '吞吐趋势',
latencyHistogram: '延迟分布',
latencyHistogram: '请求时长分布',
errorTrend: '错误趋势',
errorDistribution: '错误分布',
// Health Score & Diagnosis
@@ -2087,7 +2114,9 @@ export default {
'30m': '近30分钟',
'1h': '近1小时',
'6h': '近6小时',
'24h': '近24小时'
'24h': '近24小时',
'7d': '近7天',
'30d': '近30天'
},
fullscreen: {
enter: '进入全屏'
@@ -2116,15 +2145,8 @@ export default {
memoryHigh: '内存使用率偏高 ({usage}%)',
memoryHighImpact: '内存压力较大,需要关注',
memoryHighAction: '监控内存趋势,检查是否有内存泄漏',
// Latency diagnostics
latencyCritical: '响应延迟严重过高 ({latency}ms)',
latencyCriticalImpact: '用户体验极差,大量请求超时',
latencyCriticalAction: '检查慢查询、数据库索引、网络延迟和上游服务',
latencyHigh: '响应延迟偏高 ({latency}ms)',
latencyHighImpact: '用户体验下降,需要优化',
latencyHighAction: '分析慢请求日志,优化数据库查询和业务逻辑',
ttftHigh: '首字节时间偏高 ({ttft}ms)',
ttftHighImpact: '用户感知延迟增加',
ttftHighImpact: '用户感知时长增加',
ttftHighAction: '优化请求处理流程,减少前置逻辑耗时',
// Error rate diagnostics
upstreamCritical: '上游错误率严重偏高 ({rate}%)',
@@ -2142,13 +2164,13 @@ export default {
// SLA diagnostics
slaCritical: 'SLA 严重低于目标 ({sla}%)',
slaCriticalImpact: '用户体验严重受损',
slaCriticalAction: '紧急排查错误和延迟问题,考虑限流保护',
slaCriticalAction: '紧急排查错误原因,必要时采取限流保护',
slaLow: 'SLA 低于目标 ({sla}%)',
slaLowImpact: '需要关注服务质量',
slaLowAction: '分析SLA下降原因优化系统性能',
// Health score diagnostics
healthCritical: '综合健康评分过低 ({score})',
healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与延迟',
healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与资源使用情况',
healthCriticalAction: '全面检查系统状态优先处理critical级别问题',
healthLow: '综合健康评分偏低 ({score})',
healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率',
@@ -2159,27 +2181,106 @@ export default {
// Error Log
errorLog: {
timeId: '时间 / ID',
commonErrors: {
contextDeadlineExceeded: '请求超时',
connectionRefused: '连接被拒绝',
rateLimit: '触发限流'
},
time: '时间',
type: '类型',
context: '上下文',
platform: '平台',
model: '模型',
group: '分组',
user: '用户',
userId: '用户 ID',
account: '账号',
accountId: '账号 ID',
status: '状态码',
message: '消息',
latency: '延迟',
message: '响应内容',
latency: '请求时长',
action: '操作',
noErrors: '该窗口内暂无错误。',
grp: 'GRP',
acc: 'ACC',
details: '详情',
phase: '阶段'
phase: '阶段',
id: 'ID',
typeUpstream: '上游',
typeRequest: '请求',
typeAuth: '认证',
typeRouting: '路由',
typeInternal: '内部'
},
// Error Details Modal
errorDetails: {
upstreamErrors: '上游错误',
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: '重试次数'
},
source: {
upstream_http: '上游 HTTP'
},
upstreamKeys: {
status: '状态码',
message: '消息',
detail: '详情',
upstreamErrors: '上游错误列表'
},
upstreamEvent: {
account: '账号',
status: '状态码',
requestId: '请求ID'
},
responsePreview: {
expand: '响应内容(点击展开)',
collapse: '响应内容(点击收起)'
},
retryMeta: {
used: '使用账号',
success: '成功',
pinned: '固定账号'
},
loading: '加载中…',
requestId: '请求 ID',
time: '时间',
@@ -2189,8 +2290,10 @@ export default {
basicInfo: '基本信息',
platform: '平台',
model: '模型',
latency: '延迟',
ttft: 'TTFT',
group: '分组',
user: '用户',
account: '账号',
latency: '请求时长',
businessLimited: '业务限制',
requestPath: '请求路径',
timings: '时序信息',
@@ -2198,6 +2301,8 @@ export default {
routing: '路由',
upstream: '上游',
response: '响应',
classification: '错误分类',
notRetryable: '此错误不建议重试',
retry: '重试',
retryClient: '重试(客户端)',
retryUpstream: '重试(上游固定)',
@@ -2209,7 +2314,6 @@ export default {
confirmRetry: '确认重试',
retrySuccess: '重试成功',
retryFailed: '重试失败',
na: 'N/A',
retryHint: '重试将使用相同的请求参数重新发送请求',
retryClientHint: '使用客户端重试(不固定账号)',
retryUpstreamHint: '使用上游固定重试(固定到错误的账号)',
@@ -2217,8 +2321,33 @@ export default {
retryNote1: '重试会使用相同的请求体和参数',
retryNote2: '如果原请求失败是因为账号问题,固定重试可能仍会失败',
retryNote3: '客户端重试会重新选择账号',
retryNote4: '对不可重试的错误可以强制重试,但不推荐',
confirmRetryMessage: '确认要重试该请求吗?',
confirmRetryHint: '将使用相同的请求参数重新发送'
confirmRetryHint: '将使用相同的请求参数重新发送',
forceRetry: '我已确认并理解强制重试风险',
forceRetryHint: '此错误类型通常不可通过重试解决;如仍需重试请勾选确认',
forceRetryNeedAck: '请先勾选确认再强制重试',
markResolved: '标记已解决',
markUnresolved: '标记未解决',
viewRetries: '重试历史',
retryHistory: '重试历史',
tabOverview: '概览',
tabRetries: '重试历史',
tabRequest: '请求详情',
tabResponse: '响应详情',
responseBody: '响应详情',
compareA: '对比 A',
compareB: '对比 B',
retrySummary: '重试摘要',
responseHintSucceeded: '展示重试成功的 response_preview#{id}',
responseHintFallback: '没有成功的重试结果,展示存储的 error_body',
suggestion: '处理建议',
suggestUpstreamResolved: '✓ 上游错误已通过重试解决,无需人工介入',
suggestUpstream: '⚠️ 上游服务不稳定,建议:检查上游账号状态 / 考虑切换账号 / 再次重试',
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
suggestPlatform: '🚨 平台错误,建议立即排查修复',
suggestGeneric: '查看详情了解更多信息'
},
requestDetails: {
title: '请求明细',
@@ -2254,13 +2383,46 @@ export default {
loading: '加载中...',
empty: '暂无告警事件',
loadFailed: '加载告警事件失败',
status: {
firing: '告警中',
resolved: '已恢复',
manualResolved: '手动已解决'
},
detail: {
title: '告警详情',
loading: '加载详情中...',
empty: '暂无详情',
loadFailed: '加载告警详情失败',
manualResolve: '标记为已解决',
manualResolvedSuccess: '已标记为手动解决',
manualResolvedFailed: '标记为手动解决失败',
silence: '忽略此告警',
silenceSuccess: '已静默该告警',
silenceFailed: '静默失败',
viewRule: '查看规则',
viewLogs: '查看相关日志',
firedAt: '触发时间',
resolvedAt: '解决时间',
ruleId: '规则 ID',
dimensions: '维度信息',
historyTitle: '历史记录',
historyHint: '同一规则 + 相同维度的最近事件',
historyLoading: '加载历史中...',
historyEmpty: '暂无历史记录'
},
table: {
time: '时间',
status: '状态',
severity: '级别',
platform: '平台',
ruleId: '规则ID',
title: '标题',
duration: '持续时间',
metric: '指标 / 阈值',
email: '邮件已发送'
dimensions: '维度',
email: '邮件已发送',
emailSent: '已发送',
emailIgnored: '已忽略'
}
},
alertRules: {
@@ -2288,8 +2450,8 @@ export default {
successRate: '成功率 (%)',
errorRate: '错误率 (%)',
upstreamErrorRate: '上游错误率 (%)',
p95: 'P95 延迟 (ms)',
p99: 'P99 延迟 (ms)',
p95: 'P95 请求时长 (ms)',
p99: 'P99 请求时长 (ms)',
cpu: 'CPU 使用率 (%)',
memory: '内存使用率 (%)',
queueDepth: '并发排队深度',
@@ -2374,7 +2536,6 @@ export default {
title: '告警静默(维护模式)',
enabled: '启用静默',
globalUntil: '静默截止时间RFC3339',
untilPlaceholder: '2026-01-05T00:00:00Z',
untilHint: '建议填写截止时间,避免忘记关闭静默。',
reason: '原因',
reasonPlaceholder: '例如:计划维护',
@@ -2414,7 +2575,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: {
@@ -2479,8 +2644,6 @@ export default {
metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示',
slaMinPercent: 'SLA最低百分比',
slaMinPercentHint: 'SLA低于此值时显示为红色默认99.5%',
latencyP99MaxMs: '延迟P99最大值毫秒',
latencyP99MaxMsHint: '延迟P99高于此值时显示为红色默认2000ms',
ttftP99MaxMs: 'TTFT P99最大值毫秒',
ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色默认500ms',
requestErrorRateMaxPercent: '请求错误率最大值(%',
@@ -2499,9 +2662,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: {
@@ -2539,12 +2721,12 @@ export default {
tooltips: {
totalRequests: '当前时间窗口内的总请求数和Token消耗量。',
throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。',
latencyHistogram: '成功请求的延迟分布(毫秒)。',
latencyHistogram: '成功请求的请求时长分布(毫秒)。',
errorTrend: '错误趋势SLA 口径排除业务限制;上游错误率排除 429/529。',
errorDistribution: '按状态码统计的错误分布。',
upstreamErrors: '上游服务返回的错误包括API提供商的错误响应排除429/529限流错误。',
goroutines:
'Go 运行时的协程数量(轻量级线程)。没有绝对安全值,建议以历史基线为准。经验参考:<2000 常见2000-8000 需关注;>8000 且伴随队列/延迟上升时,优先排查阻塞/泄漏。',
'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。',
cpu: 'CPU 使用率,显示系统处理器的负载情况。',
memory: '内存使用率,包括已使用和总可用内存。',
db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。',
@@ -2554,7 +2736,7 @@ export default {
tokens: '当前时间窗口内处理的总Token数量。',
sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。',
errors: '错误统计,包括总错误数、错误率和上游错误率。',
latency: '请求延迟统计,包括 p50、p90、p95、p99 等百分位数。',
latency: '请求时长统计,包括 p50、p90、p95、p99 等百分位数。',
ttft: '首Token延迟Time To First Token衡量流式响应的首字节返回速度。',
health: '系统健康评分0-100综合考虑 SLA、错误率和资源使用情况。'
},

View File

@@ -345,7 +345,7 @@
.modal-overlay {
@apply fixed inset-0 z-50;
@apply bg-black/50 backdrop-blur-sm;
@apply flex items-center justify-center p-4;
@apply flex items-center justify-center p-2 sm:p-4;
}
.modal-content {

View File

@@ -364,10 +364,21 @@ export interface Proxy {
password?: string | null
status: 'active' | 'inactive'
account_count?: number // Number of accounts using this proxy
latency_ms?: number
latency_status?: 'success' | 'failed'
latency_message?: string
created_at: string
updated_at: string
}
export interface ProxyAccountSummary {
id: number
name: string
platform: AccountPlatform
type: AccountType
notes?: string | null
}
// Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials {
// API Key authentication
@@ -428,6 +439,7 @@ export interface Account {
concurrency: number
current_concurrency?: number // Real-time concurrency count from Redis
priority: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
status: 'active' | 'inactive' | 'error'
error_message: string | null
last_used_at: string | null
@@ -457,7 +469,9 @@ export interface Account {
export interface WindowStats {
requests: number
tokens: number
cost: number
cost: number // Account cost (account multiplier)
standard_cost?: number
user_cost?: number
}
export interface UsageProgress {
@@ -522,6 +536,7 @@ export interface CreateAccountRequest {
proxy_id?: number | null
concurrency?: number
priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
@@ -537,6 +552,7 @@ export interface UpdateAccountRequest {
proxy_id?: number | null
concurrency?: number
priority?: number
rate_multiplier?: number // Account billing multiplier (>=0, 0 means free)
schedulable?: boolean
status?: 'active' | 'inactive'
group_ids?: number[]
@@ -593,6 +609,7 @@ export interface UsageLog {
total_cost: number
actual_cost: number
rate_multiplier: number
account_rate_multiplier?: number | null
stream: boolean
duration_ms: number
@@ -852,23 +869,27 @@ export interface AccountUsageHistory {
requests: number
tokens: number
cost: number
actual_cost: number
actual_cost: number // Account cost (account multiplier)
user_cost: number // User/API key billed cost (group multiplier)
}
export interface AccountUsageSummary {
days: number
actual_days_used: number
total_cost: number
total_cost: number // Account cost (account multiplier)
total_user_cost: number
total_standard_cost: number
total_requests: number
total_tokens: number
avg_daily_cost: number
avg_daily_cost: number // Account cost
avg_daily_user_cost: number
avg_daily_requests: number
avg_daily_tokens: number
avg_duration_ms: number
today: {
date: string
cost: number
user_cost: number
requests: number
tokens: number
} | null
@@ -876,6 +897,7 @@ export interface AccountUsageSummary {
date: string
label: string
cost: number
user_cost: number
requests: number
} | null
highest_request_day: {
@@ -883,6 +905,7 @@ export interface AccountUsageSummary {
label: string
requests: number
cost: number
user_cost: number
} | null
}

View File

@@ -61,6 +61,11 @@
<template #cell-usage="{ row }">
<AccountUsageCell :account="row" />
</template>
<template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
</span>
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
@@ -120,7 +125,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
@@ -190,10 +195,11 @@ const cols = computed(() => {
if (!authStore.isSimpleMode) {
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
}
c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
@@ -202,7 +208,56 @@ const cols = computed(() => {
})
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true }
const openMenu = (a: Account, e: MouseEvent) => {
menu.acc = a
const target = e.currentTarget as HTMLElement
if (target) {
const rect = target.getBoundingClientRect()
const menuWidth = 200
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left, top
if (viewportWidth < 768) {
// 居中显示,水平位置
left = Math.max(padding, Math.min(
rect.left + rect.width / 2 - menuWidth / 2,
viewportWidth - menuWidth - padding
))
// 优先显示在按钮下方
top = rect.bottom + 4
// 如果下方空间不够,显示在上方
if (top + menuHeight > viewportHeight - padding) {
top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
}
} else {
left = Math.max(padding, Math.min(
e.clientX - menuWidth,
viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
}
}
menu.pos = { top, left }
} else {
menu.pos = { top: e.clientY, left: e.clientX - 200 }
}
menu.show = true
}
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
@@ -360,5 +415,14 @@ const isExpired = (value: number | null) => {
return value * 1000 <= Date.now()
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
// 滚动时关闭菜单
const handleScroll = () => {
menu.show = false
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) }; window.addEventListener('scroll', handleScroll, true) })
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll, true)
})
</script>

View File

@@ -51,6 +51,24 @@
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="handleBatchTest"
:disabled="batchTesting || loading"
class="btn btn-secondary"
:title="t('admin.proxies.testConnection')"
>
<Icon name="play" size="md" class="mr-2" />
{{ t('admin.proxies.testConnection') }}
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
class="btn btn-danger"
:title="t('admin.proxies.batchDeleteAction')"
>
<Icon name="trash" size="md" class="mr-2" />
{{ t('admin.proxies.batchDeleteAction') }}
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }}
@@ -61,6 +79,26 @@
<template #table>
<DataTable :columns="columns" :data="proxies" :loading="loading">
<template #header-select>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="allVisibleSelected"
@click.stop
@change="toggleSelectAllVisible($event)"
/>
</template>
<template #cell-select="{ row }">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="selectedProxyIds.has(row.id)"
@click.stop
@change="toggleSelectRow(row.id, $event)"
/>
</template>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
@@ -79,17 +117,43 @@
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.accounts.status.' + value) }}
<template #cell-account_count="{ row, value }">
<button
v-if="(value || 0) > 0"
type="button"
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-primary-700 hover:bg-gray-200 dark:bg-dark-600 dark:text-primary-300 dark:hover:bg-dark-500"
@click="openAccountsModal(row)"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</button>
<span
v-else
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{ t('admin.groups.accountsCount', { count: 0 }) }}
</span>
</template>
<template #cell-account_count="{ value }">
<template #cell-latency="{ row }">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
v-if="row.latency_status === 'failed'"
class="badge badge-danger"
:title="row.latency_message || undefined"
>
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
{{ t('admin.proxies.latencyFailed') }}
</span>
<span
v-else-if="typeof row.latency_ms === 'number'"
:class="['badge', row.latency_ms < 200 ? 'badge-success' : 'badge-warning']"
>
{{ row.latency_ms }}ms
</span>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.accounts.status.' + value) }}
</span>
</template>
@@ -515,6 +579,63 @@
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Batch Delete Confirmation Dialog -->
<ConfirmDialog
:show="showBatchDeleteDialog"
:title="t('admin.proxies.batchDelete')"
:message="t('admin.proxies.batchDeleteConfirm', { count: selectedCount })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmBatchDelete"
@cancel="showBatchDeleteDialog = false"
/>
<!-- Proxy Accounts Dialog -->
<BaseDialog
:show="showAccountsModal"
:title="t('admin.proxies.accountsTitle', { name: accountsProxy?.name || '' })"
width="normal"
@close="closeAccountsModal"
>
<div v-if="accountsLoading" class="flex items-center justify-center py-8 text-sm text-gray-500">
<Icon name="refresh" size="md" class="mr-2 animate-spin" />
{{ t('common.loading') }}
</div>
<div v-else-if="proxyAccounts.length === 0" class="py-6 text-center text-sm text-gray-500">
{{ t('admin.proxies.accountsEmpty') }}
</div>
<div v-else class="max-h-80 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-700">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-dark-800 dark:text-dark-400">
<tr>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountName') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.accounts.columns.platformType') }}</th>
<th class="px-4 py-2 text-left">{{ t('admin.proxies.accountNotes') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-for="account in proxyAccounts" :key="account.id">
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{{ account.name }}</td>
<td class="px-4 py-2">
<PlatformTypeBadge :platform="account.platform" :type="account.type" />
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
{{ account.notes || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="closeAccountsModal" class="btn btn-secondary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</AppLayout>
</template>
@@ -523,7 +644,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types'
import type { Proxy, ProxyAccountSummary, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -534,15 +655,18 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
const { t } = useI18n()
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
])
@@ -592,11 +716,24 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
const showAccountsModal = ref(false)
const submitting = ref(false)
const testingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)
const editingProxy = ref<Proxy | null>(null)
const deletingProxy = ref<Proxy | null>(null)
const selectedCount = computed(() => selectedProxyIds.value.size)
const allVisibleSelected = computed(() => {
if (proxies.value.length === 0) return false
return proxies.value.every((proxy) => selectedProxyIds.value.has(proxy.id))
})
// Batch import state
const createMode = ref<'standard' | 'batch'>('standard')
const batchInput = ref('')
@@ -641,6 +778,30 @@ const isAbortError = (error: unknown) => {
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
}
const toggleSelectRow = (id: number, event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
if (target.checked) {
next.add(id)
} else {
next.delete(id)
}
selectedProxyIds.value = next
}
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
const next = new Set(selectedProxyIds.value)
for (const proxy of proxies.value) {
if (target.checked) {
next.add(proxy.id)
} else {
next.delete(proxy.id)
}
}
selectedProxyIds.value = next
}
const loadProxies = async () => {
if (abortController) {
abortController.abort()
@@ -895,35 +1056,151 @@ const handleUpdateProxy = async () => {
}
}
const handleTestConnection = async (proxy: Proxy) => {
// Create new Set to trigger reactivity
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
const applyLatencyResult = (
proxyId: number,
result: { success: boolean; latency_ms?: number; message?: string }
) => {
const target = proxies.value.find((proxy) => proxy.id === proxyId)
if (!target) return
if (result.success) {
target.latency_status = 'success'
target.latency_ms = result.latency_ms
} else {
target.latency_status = 'failed'
target.latency_ms = undefined
}
target.latency_message = result.message
}
const startTestingProxy = (proxyId: number) => {
testingProxyIds.value = new Set([...testingProxyIds.value, proxyId])
}
const stopTestingProxy = (proxyId: number) => {
const next = new Set(testingProxyIds.value)
next.delete(proxyId)
testingProxyIds.value = next
}
const runProxyTest = async (proxyId: number, notify: boolean) => {
startTestingProxy(proxyId)
try {
const result = await adminAPI.proxies.testProxy(proxy.id)
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
const result = await adminAPI.proxies.testProxy(proxyId)
applyLatencyResult(proxyId, result)
if (notify) {
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
}
}
return result
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
const message = error.response?.data?.detail || t('admin.proxies.failedToTest')
applyLatencyResult(proxyId, { success: false, message })
if (notify) {
appStore.showError(message)
}
console.error('Error testing proxy:', error)
return null
} finally {
// Create new Set without this proxy id to trigger reactivity
const newSet = new Set(testingProxyIds.value)
newSet.delete(proxy.id)
testingProxyIds.value = newSet
stopTestingProxy(proxyId)
}
}
const handleTestConnection = async (proxy: Proxy) => {
await runProxyTest(proxy.id, true)
}
const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
const pageSize = 200
const result: Proxy[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const response = await adminAPI.proxies.list(
page,
pageSize,
{
protocol: filters.protocol || undefined,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
result.push(...response.items)
totalPages = response.pages || 1
page++
}
return result
}
const runBatchProxyTests = async (ids: number[]) => {
if (ids.length === 0) return
const concurrency = 5
let index = 0
const worker = async () => {
while (index < ids.length) {
const current = ids[index]
index++
await runProxyTest(current, false)
}
}
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
await Promise.all(workers)
}
const handleBatchTest = async () => {
if (batchTesting.value) return
batchTesting.value = true
try {
let ids: number[] = []
if (selectedCount.value > 0) {
ids = Array.from(selectedProxyIds.value)
} else {
const allProxies = await fetchAllProxiesForBatch()
ids = allProxies.map((proxy) => proxy.id)
}
if (ids.length === 0) {
appStore.showInfo(t('admin.proxies.batchTestEmpty'))
return
}
await runBatchProxyTests(ids)
appStore.showSuccess(t('admin.proxies.batchTestDone', { count: ids.length }))
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchTestFailed'))
console.error('Error batch testing proxies:', error)
} finally {
batchTesting.value = false
}
}
const handleDelete = (proxy: Proxy) => {
if ((proxy.account_count || 0) > 0) {
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
return
}
deletingProxy.value = proxy
showDeleteDialog.value = true
}
const openBatchDelete = () => {
if (selectedCount.value === 0) {
return
}
showBatchDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingProxy.value) return
@@ -931,6 +1208,11 @@ const confirmDelete = async () => {
await adminAPI.proxies.delete(deletingProxy.value.id)
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
showDeleteDialog.value = false
if (selectedProxyIds.value.has(deletingProxy.value.id)) {
const next = new Set(selectedProxyIds.value)
next.delete(deletingProxy.value.id)
selectedProxyIds.value = next
}
deletingProxy.value = null
loadProxies()
} catch (error: any) {
@@ -939,6 +1221,55 @@ const confirmDelete = async () => {
}
}
const confirmBatchDelete = async () => {
const ids = Array.from(selectedProxyIds.value)
if (ids.length === 0) {
showBatchDeleteDialog.value = false
return
}
try {
const result = await adminAPI.proxies.batchDelete(ids)
const deleted = result.deleted_ids?.length || 0
const skipped = result.skipped?.length || 0
if (deleted > 0) {
appStore.showSuccess(t('admin.proxies.batchDeleteDone', { deleted, skipped }))
} else if (skipped > 0) {
appStore.showInfo(t('admin.proxies.batchDeleteSkipped', { skipped }))
}
selectedProxyIds.value = new Set()
showBatchDeleteDialog.value = false
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.batchDeleteFailed'))
console.error('Error batch deleting proxies:', error)
}
}
const openAccountsModal = async (proxy: Proxy) => {
accountsProxy.value = proxy
proxyAccounts.value = []
accountsLoading.value = true
showAccountsModal.value = true
try {
proxyAccounts.value = await adminAPI.proxies.getProxyAccounts(proxy.id)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.accountsFailed'))
console.error('Error loading proxy accounts:', error)
} finally {
accountsLoading.value = false
}
}
const closeAccountsModal = () => {
showAccountsModal.value = false
accountsProxy.value = null
proxyAccounts.value = []
}
onMounted(() => {
loadProxies()
})

View File

@@ -100,7 +100,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.original'), t('usage.billed'),
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
]
@@ -121,8 +121,10 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000',
log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toFixed(2) || '1.00',
(log.account_rate_multiplier ?? 1).toFixed(2),
log.total_cost?.toFixed(6) || '0.000000',
log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
log.first_token_ms ?? '',
log.duration_ms,
log.request_id || '',

View File

@@ -3,11 +3,11 @@
<TablePageLayout>
<!-- Single Row: Search, Filters, and Actions -->
<template #filters>
<div class="flex w-full flex-wrap-reverse items-center justify-between gap-4">
<div class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap-reverse md:items-center md:justify-between md:gap-4">
<!-- Left: Search + Active Filters -->
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3">
<div class="flex min-w-[280px] flex-1 flex-wrap content-start items-center gap-3 md:order-1">
<!-- Search Box -->
<div class="relative w-full sm:w-64">
<div class="relative w-full md:w-64">
<Icon
name="search"
size="md"
@@ -100,109 +100,119 @@
</div>
<!-- Right: Actions and Settings -->
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
<!-- Refresh Button -->
<button
@click="loadUsers"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<!-- Filter Settings Dropdown -->
<div class="relative" ref="filterDropdownRef">
<div class="flex w-full items-center justify-between gap-2 md:order-2 md:ml-auto md:max-w-full md:flex-wrap md:justify-end md:gap-3">
<!-- Mobile: Secondary buttons (icon only) -->
<div class="flex items-center gap-2 md:contents">
<!-- Refresh Button -->
<button
@click="showFilterDropdown = !showFilterDropdown"
class="btn btn-secondary"
@click="loadUsers"
:disabled="loading"
class="btn btn-secondary px-2 md:px-3"
:title="t('common.refresh')"
>
<Icon name="filter" size="sm" class="mr-1.5" />
{{ t('admin.users.filterSettings') }}
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<!-- Dropdown menu -->
<div
v-if="showFilterDropdown"
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<!-- Built-in filters -->
<!-- Filter Settings Dropdown -->
<div class="relative" ref="filterDropdownRef">
<button
v-for="filter in builtInFilters"
:key="filter.key"
@click="toggleBuiltInFilter(filter.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
@click="showFilterDropdown = !showFilterDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.filterSettings')"
>
<span>{{ filter.name }}</span>
<Icon
v-if="visibleFilters.has(filter.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
<Icon name="filter" size="sm" class="md:mr-1.5" />
<span class="hidden md:inline">{{ t('admin.users.filterSettings') }}</span>
</button>
<!-- Divider if custom attributes exist -->
<!-- Dropdown menu -->
<div
v-if="filterableAttributes.length > 0"
class="my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Custom attribute filters -->
<button
v-for="attr in filterableAttributes"
:key="attr.id"
@click="toggleAttributeFilter(attr)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
v-if="showFilterDropdown"
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<span>{{ attr.name }}</span>
<Icon
v-if="visibleFilters.has(`attr_${attr.id}`)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
<!-- Built-in filters -->
<button
v-for="filter in builtInFilters"
:key="filter.key"
@click="toggleBuiltInFilter(filter.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ filter.name }}</span>
<Icon
v-if="visibleFilters.has(filter.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
<!-- Divider if custom attributes exist -->
<div
v-if="filterableAttributes.length > 0"
class="my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Custom attribute filters -->
<button
v-for="attr in filterableAttributes"
:key="attr.id"
@click="toggleAttributeFilter(attr)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ attr.name }}</span>
<Icon
v-if="visibleFilters.has(`attr_${attr.id}`)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div>
</div>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.columnSettings')"
>
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
</button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div>
<!-- Attributes Config Button -->
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary"
@click="showAttributesModal = true"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.attributes.configButton')"
>
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
</svg>
{{ t('admin.users.columnSettings') }}
<Icon name="cog" size="sm" class="md:mr-1.5" />
<span class="hidden md:inline">{{ t('admin.users.attributes.configButton') }}</span>
</button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div>
<!-- Attributes Config Button -->
<button @click="showAttributesModal = true" class="btn btn-secondary">
<Icon name="cog" size="sm" class="mr-1.5" />
{{ t('admin.users.attributes.configButton') }}
</button>
<!-- Create User Button -->
<button @click="showCreateModal = true" class="btn btn-primary">
<!-- Create User Button (full width on mobile, auto width on desktop) -->
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.users.createUser') }}
</button>
@@ -362,8 +372,7 @@
<!-- More Actions Menu Trigger -->
<button
:ref="(el) => setActionButtonRef(row.id, el)"
@click="openActionMenu(row)"
@click="openActionMenu(row, $event)"
class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
>
@@ -475,7 +484,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
@@ -735,42 +744,56 @@ let abortController: AbortController | null = null
// Action Menu State
const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(userId, el)
} else {
actionButtonRefs.value.delete(userId)
}
}
const openActionMenu = (user: User) => {
const openActionMenu = (user: User, e: MouseEvent) => {
if (activeMenuId.value === user.id) {
closeActionMenu()
} else {
const buttonEl = actionButtonRefs.value.get(user.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const menuWidth = 192
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const left = Math.min(
Math.max(rect.right - menuWidth, padding),
Math.max(viewportWidth - menuWidth - padding, padding)
)
let top = rect.bottom + 4
const target = e.currentTarget as HTMLElement
if (!target) {
closeActionMenu()
return
}
const rect = target.getBoundingClientRect()
const menuWidth = 200
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left, top
if (viewportWidth < 768) {
// 居中显示,水平位置
left = Math.max(padding, Math.min(
rect.left + rect.width / 2 - menuWidth / 2,
viewportWidth - menuWidth - padding
))
// 优先显示在按钮下方
top = rect.bottom + 4
// 如果下方空间不够,显示在上方
if (top + menuHeight > viewportHeight - padding) {
top = Math.max(rect.top - menuHeight - 4, padding)
top = rect.top - menuHeight - 4
// 如果上方也不够,就贴在视口顶部
if (top < padding) {
top = padding
}
}
// Position menu near the trigger, clamped to viewport
menuPosition.value = {
top,
left
} else {
left = Math.max(padding, Math.min(
e.clientX - menuWidth,
viewportWidth - menuWidth - padding
))
top = e.clientY
if (top + menuHeight > viewportHeight - padding) {
top = viewportHeight - menuHeight - padding
}
}
menuPosition.value = { top, left }
activeMenuId.value = user.id
}
}
@@ -1054,16 +1077,24 @@ const closeBalanceModal = () => {
showBalanceModal.value = false
balanceUser.value = null
}
// 滚动时关闭菜单
const handleScroll = () => {
closeActionMenu()
}
onMounted(async () => {
await loadAttributeDefinitions()
loadSavedFilters()
loadSavedColumns()
loadUsers()
document.addEventListener('click', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', handleScroll, true)
clearTimeout(searchTimeout)
abortController?.abort()
})

View File

@@ -8,7 +8,7 @@
{{ errorMessage }}
</div>
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" />
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" :fullscreen="isFullscreen" />
<OpsDashboardHeader
v-else-if="opsEnabled"
@@ -94,7 +94,7 @@
@openErrorDetail="openError"
/>
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="errorDetailsType" />
<OpsRequestDetailsModal
v-model="showRequestDetails"
@@ -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()
@@ -376,11 +400,17 @@ function handleOpenRequestDetails(preset?: OpsRequestDetailsPreset) {
requestDetailsPreset.value = { ...basePreset, ...(preset ?? {}) }
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
}
function openErrorDetails(kind: 'request' | 'upstream') {
errorDetailsType.value = kind
// Ensure only one modal visible at a time.
showRequestDetails.value = false
showErrorModal.value = false
showErrorDetails.value = true
}
@@ -422,6 +452,9 @@ function onQueryModeChange(v: string | number | boolean | null) {
function openError(id: number) {
selectedErrorId.value = id
// Ensure only one modal visible at a time.
showErrorDetails.value = false
showRequestDetails.value = false
showErrorModal.value = true
}

View File

@@ -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 loading = ref(false)
const events = ref<AlertEvent[]>([])
const PAGE_SIZE = 10
const limit = ref(100)
const limitOptions = computed(() => [
{ value: 50, label: '50' },
{ value: 100, label: '100' },
{ value: 200, label: '200' }
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') }
])
async function load() {
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 emailSent = ref<string>('')
const emailSentOptions = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'true', label: t('admin.ops.alertEvents.table.emailSent') },
{ value: 'false', label: t('admin.ops.alertEvents.table.emailIgnored') }
])
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(limit, () => {
load()
watch([timeRange, severity, status, emailSent], () => {
events.value = []
hasMore.value = true
loadFirstPage()
})
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="loadFirstPage"
>
<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.status') }}
{{ t('admin.ops.alertEvents.table.severity') }}
</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>
<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">
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
{{ row.severity || '-' }}
</span>
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
{{ getDimensionString(row, 'platform') || '-' }}
</td>
<td class="min-w-[280px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
<div class="font-semibold">{{ row.title || '-' }}</div>
<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-[260px] 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>

View File

@@ -140,24 +140,6 @@ const metricDefinitions = computed(() => {
recommendedThreshold: 1,
unit: '%'
},
{
type: 'p95_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p95'),
description: t('admin.ops.alertRules.metricDescriptions.p95'),
recommendedOperator: '>',
recommendedThreshold: 1000,
unit: 'ms'
},
{
type: 'p99_latency_ms',
group: 'system',
label: t('admin.ops.alertRules.metrics.p99'),
description: t('admin.ops.alertRules.metricDescriptions.p99'),
recommendedOperator: '>',
recommendedThreshold: 2000,
unit: 'ms'
},
{
type: 'cpu_usage_percent',
group: 'system',

View File

@@ -169,8 +169,8 @@ const updatedAtLabel = computed(() => {
return props.lastUpdated.toLocaleTimeString()
})
// --- Color coding for latency/TTFT ---
function getLatencyColor(ms: number | null | undefined): string {
// --- Color coding for TTFT ---
function getTTFTColor(ms: number | null | undefined): string {
if (ms == null) return 'text-gray-900 dark:text-white'
if (ms < 500) return 'text-green-600 dark:text-green-400'
if (ms < 1000) return 'text-yellow-600 dark:text-yellow-400'
@@ -186,13 +186,6 @@ function isSLABelowThreshold(slaPercent: number | null): boolean {
return slaPercent < threshold
}
function isLatencyAboveThreshold(latencyP99Ms: number | null): boolean {
if (latencyP99Ms == null) return false
const threshold = props.thresholds?.latency_p99_ms_max
if (threshold == null) return false
return latencyP99Ms > threshold
}
function isTTFTAboveThreshold(ttftP99Ms: number | null): boolean {
if (ttftP99Ms == null) return false
const threshold = props.thresholds?.ttft_p99_ms_max
@@ -482,24 +475,6 @@ const diagnosisReport = computed<DiagnosisItem[]>(() => {
}
}
// Latency diagnostics
const durationP99 = ov.duration?.p99_ms ?? 0
if (durationP99 > 2000) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.latencyCritical', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyCriticalImpact'),
action: t('admin.ops.diagnosis.latencyCriticalAction')
})
} else if (durationP99 > 1000) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.latencyHigh', { latency: durationP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.latencyHighImpact'),
action: t('admin.ops.diagnosis.latencyHighAction')
})
}
const ttftP99 = ov.ttft?.p99_ms ?? 0
if (ttftP99 > 500) {
report.push({
@@ -851,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>
@@ -1113,7 +1088,7 @@ function handleToolbarRefresh() {
</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>
@@ -1130,7 +1105,7 @@ function handleToolbarRefresh() {
</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>
@@ -1145,7 +1120,7 @@ function handleToolbarRefresh() {
</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>
@@ -1181,7 +1156,7 @@ function handleToolbarRefresh() {
<!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 1;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span>
@@ -1217,10 +1192,10 @@ function handleToolbarRefresh() {
</div>
<!-- Card 2: SLA -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<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>
@@ -1247,8 +1222,8 @@ function handleToolbarRefresh() {
</div>
</div>
<!-- Card 3: Latency (Duration) -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<!-- Card 4: Request Duration -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 4;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
@@ -1264,42 +1239,42 @@ function handleToolbarRefresh() {
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="isLatencyAboveThreshold(durationP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(durationP99Ms)">
<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>
</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="font-bold" :class="getLatencyColor(durationP95Ms)">{{ durationP95Ms ?? '-' }}</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>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(durationP90Ms)">{{ durationP90Ms ?? '-' }}</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>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(durationP50Ms)">{{ durationP50Ms ?? '-' }}</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>
</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="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationAvgMs ?? '-' }}</span>
<span class="text-gray-400">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="getLatencyColor(durationMaxMs)">{{ durationMaxMs ?? '-' }}</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 4: TTFT -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<!-- Card 5: TTFT -->
<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>
@@ -1309,48 +1284,48 @@ function handleToolbarRefresh() {
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>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="isTTFTAboveThreshold(ttftP99Ms) ? 'text-red-600 dark:text-red-400' : getLatencyColor(ttftP99Ms)">
<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>
</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="font-bold" :class="getLatencyColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}</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>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}</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>
</div>
<div class="flex min-w-[60px] items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}</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>
</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="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span>
<span class="font-bold" :class="getTTFTColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}</span>
<span class="text-gray-400">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="getLatencyColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span>
<span class="font-bold" :class="getTTFTColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 5: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<!-- Card 3: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 3;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
@@ -1376,7 +1351,7 @@ function handleToolbarRefresh() {
</div>
<!-- Card 6: Upstream Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 6;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
@@ -1423,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">
@@ -1441,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">

View File

@@ -1,50 +1,96 @@
<script setup lang="ts">
interface Props {
fullscreen?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fullscreen: false
})
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<!-- Header (matches OpsDashboardHeader + overview blocks) -->
<div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div class="space-y-2">
<div class="h-5 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-72 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-6 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="flex items-center gap-3">
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-3">
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[160px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[150px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-9 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div v-for="i in 4" :key="i" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-3 h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-12">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30 lg:col-span-5">
<div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
<div class="h-28 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
<div class="space-y-4">
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="grid grid-cols-2 gap-3">
<div v-for="i in 4" :key="i" class="h-14 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-7">
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="i in 6" :key="i" class="h-20 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"></div>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
<!-- Cards -->
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-2', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
<!-- Row: Visual Analysis (baseline 3-up grid) -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div
v-for="i in 3"
:key="i"
class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
:class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']"
>
<div class="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-4 space-y-3">
<div class="h-3 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-1/2 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-3/5 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-56 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
</div>
<!-- Alert Events -->
<div :class="['rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-2">
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
<div class="h-9 w-[120px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
<div class="mt-6 space-y-3">
<div v-for="i in 6" :key="i" class="flex items-center justify-between gap-4 rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="flex-1 space-y-2">
<div class="h-3 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="h-7 w-20 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</div>

View File

@@ -12,12 +12,12 @@
</div>
<div v-else class="space-y-6 p-6">
<!-- Top Summary -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<!-- Summary -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestId') }}</div>
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
{{ detail.request_id || detail.client_request_id || '—' }}
{{ requestId || '—' }}
</div>
</div>
@@ -29,277 +29,149 @@
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.phase') }}</div>
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">
{{ detail.phase || '—' }}
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
{{ isUpstreamError(detail) ? t('admin.ops.errorDetail.account') : t('admin.ops.errorDetail.user') }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ detail.type || '—' }}
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
<template v-if="isUpstreamError(detail)">
{{ detail.account_name || (detail.account_id != null ? String(detail.account_id) : '') }}
</template>
<template v-else>
{{ detail.user_email || (detail.user_id != null ? String(detail.user_id) : '') }}
</template>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.platform || '—' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.group') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.group_name || (detail.group_id != null ? String(detail.group_id) : '—') }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.model || '—' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.status') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<div class="mt-1">
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
{{ detail.status_code }}
</span>
<span
v-if="detail.severity"
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', severityClass]"
>
{{ detail.severity }}
</span>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.message') }}</div>
<div class="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white" :title="detail.message">
{{ detail.message || '—' }}
</div>
</div>
</div>
<!-- Message -->
<!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.message') }}</h3>
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words">
{{ detail.message || '—' }}
</div>
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') }}</h3>
<pre class="mt-4 max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(primaryResponseBody || '') }}</code></pre>
</div>
<!-- Basic Info -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.basicInfo') }}</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.platform || '—' }}</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.model || '—' }}</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.latency') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.latency_ms != null ? `${detail.latency_ms}ms` : '—' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.ttft') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.time_to_first_token_ms != null ? `${detail.time_to_first_token_ms}ms` : '—' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.businessLimited') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ detail.is_business_limited ? 'true' : 'false' }}
</div>
</div>
<div>
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.requestPath') }}</div>
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200 break-all">
{{ detail.request_path || '—' }}
</div>
</div>
</div>
</div>
<!-- Timings (best-effort fields) -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.timings') }}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.auth') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.auth_latency_ms != null ? `${detail.auth_latency_ms}ms` : '—' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.routing') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.routing_latency_ms != null ? `${detail.routing_latency_ms}ms` : '—' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstream') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.upstream_latency_ms != null ? `${detail.upstream_latency_ms}ms` : '—' }}
</div>
</div>
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.response') }}</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.response_latency_ms != null ? `${detail.response_latency_ms}ms` : '—' }}
</div>
</div>
</div>
</div>
<!-- Retry -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start">
<div class="space-y-1">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetail.retryNote1') }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="retrying" @click="openRetryConfirm('client')">
{{ t('admin.ops.errorDetail.retryClient') }}
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="retrying || !pinnedAccountId"
@click="openRetryConfirm('upstream')"
:title="pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')"
>
{{ t('admin.ops.errorDetail.retryUpstream') }}
</button>
</div>
<!-- Upstream errors list (only for request errors) -->
<div v-if="showUpstreamList" class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetails.upstreamErrors') }}</h3>
<div class="text-xs text-gray-500 dark:text-gray-400" v-if="correlatedUpstreamLoading">{{ t('common.loading') }}</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="md:col-span-1">
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label>
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" :placeholder="t('admin.ops.errorDetail.pinnedAccountIdHint')" />
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.errorDetail.retryNote2') }}
</div>
</div>
<div class="md:col-span-2">
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.retryNotes') }}</div>
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs text-gray-600 dark:text-gray-300">
<li>{{ t('admin.ops.errorDetail.retryNote3') }}</li>
<li>{{ t('admin.ops.errorDetail.retryNote4') }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Upstream errors -->
<div
v-if="detail.upstream_status_code || detail.upstream_error_message || detail.upstream_error_detail || detail.upstream_errors"
class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900"
>
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">
{{ t('admin.ops.errorDetails.upstreamErrors') }}
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<div class="text-xs font-bold uppercase text-gray-400">status</div>
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
{{ detail.upstream_status_code != null ? detail.upstream_status_code : '—' }}
</div>
</div>
<div class="sm:col-span-2">
<div class="text-xs font-bold uppercase text-gray-400">message</div>
<div class="mt-1 break-words text-sm font-medium text-gray-900 dark:text-white">
{{ detail.upstream_error_message || '—' }}
</div>
</div>
<div v-if="!correlatedUpstreamLoading && !correlatedUpstreamErrors.length" class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ t('common.noData') }}
</div>
<div v-if="detail.upstream_error_detail" class="mt-4">
<div class="text-xs font-bold uppercase text-gray-400">detail</div>
<pre
class="mt-2 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.upstream_error_detail) }}</code></pre>
</div>
<div v-if="detail.upstream_errors" class="mt-5">
<div class="mb-2 text-xs font-bold uppercase text-gray-400">upstream_errors</div>
<div v-if="upstreamErrors.length" class="space-y-3">
<div
v-for="(ev, idx) in upstreamErrors"
:key="idx"
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs font-black text-gray-800 dark:text-gray-100">
#{{ idx + 1 }} <span v-if="ev.kind" class="font-mono">{{ ev.kind }}</span>
</div>
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
{{ ev.at_unix_ms ? formatDateTime(new Date(ev.at_unix_ms)) : '' }}
</div>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-3">
<div><span class="text-gray-400">account_id:</span> <span class="font-mono">{{ ev.account_id ?? '—' }}</span></div>
<div><span class="text-gray-400">status:</span> <span class="font-mono">{{ ev.upstream_status_code ?? '—' }}</span></div>
<div class="break-all">
<span class="text-gray-400">request_id:</span> <span class="font-mono">{{ ev.upstream_request_id || '—' }}</span>
</div>
</div>
<div v-if="ev.message" class="mt-2 break-words text-sm font-medium text-gray-900 dark:text-white">
{{ ev.message }}
</div>
<pre
v-if="ev.detail"
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
><code>{{ prettyJSON(ev.detail) }}</code></pre>
</div>
</div>
<pre
v-else
class="max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.upstream_errors) }}</code></pre>
</div>
</div>
<!-- Request body -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<div class="flex items-center justify-between">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.requestBody') }}</h3>
<div v-else class="mt-4 space-y-3">
<div
v-if="detail.request_body_truncated"
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
v-for="(ev, idx) in correlatedUpstreamErrors"
:key="ev.id"
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
>
{{ t('admin.ops.errorDetail.trimmed') }}
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs font-black text-gray-900 dark:text-white">
#{{ idx + 1 }}
<span v-if="ev.type" class="ml-2 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-[10px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">{{ ev.type }}</span>
</div>
<div class="flex items-center gap-2">
<div class="font-mono text-xs text-gray-500 dark:text-gray-400">
{{ ev.status_code ?? '—' }}
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[10px] font-bold text-primary-700 hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-primary-200 dark:hover:bg-dark-700"
:disabled="!getUpstreamResponsePreview(ev)"
:title="getUpstreamResponsePreview(ev) ? '' : t('common.noData')"
@click="toggleUpstreamDetail(ev.id)"
>
<Icon
:name="expandedUpstreamDetailIds.has(ev.id) ? 'chevronDown' : 'chevronRight'"
size="xs"
:stroke-width="2"
/>
<span>
{{
expandedUpstreamDetailIds.has(ev.id)
? t('admin.ops.errorDetail.responsePreview.collapse')
: t('admin.ops.errorDetail.responsePreview.expand')
}}
</span>
</button>
</div>
</div>
<div class="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
<div>
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.status') }}:</span>
<span class="ml-1 font-mono">{{ ev.status_code ?? '—' }}</span>
</div>
<div>
<span class="text-gray-400">{{ t('admin.ops.errorDetail.upstreamEvent.requestId') }}:</span>
<span class="ml-1 font-mono">{{ ev.request_id || ev.client_request_id || '—' }}</span>
</div>
</div>
<div v-if="ev.message" class="mt-3 break-words text-sm font-medium text-gray-900 dark:text-white">{{ ev.message }}</div>
<pre
v-if="expandedUpstreamDetailIds.has(ev.id)"
class="mt-3 max-h-[240px] overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100"
><code>{{ prettyJSON(getUpstreamResponsePreview(ev)) }}</code></pre>
</div>
</div>
<pre
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.request_body) }}</code></pre>
</div>
<!-- Error body -->
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.errorBody') }}</h3>
<pre
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
><code>{{ prettyJSON(detail.error_body) }}</code></pre>
</div>
</div>
</BaseDialog>
<ConfirmDialog
:show="showRetryConfirm"
:title="t('admin.ops.errorDetail.confirmRetry')"
:message="retryConfirmMessage"
@confirm="runConfirmedRetry"
@cancel="cancelRetry"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail, type OpsRetryMode } from '@/api/admin/ops'
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format'
import { getSeverityClass } from '../utils/opsFormatters'
interface Props {
show: boolean
errorId: number | null
errorType?: 'request' | 'upstream'
}
interface Emits {
@@ -315,53 +187,76 @@ const appStore = useAppStore()
const loading = ref(false)
const detail = ref<OpsErrorDetail | null>(null)
const retrying = ref(false)
const showRetryConfirm = ref(false)
const pendingRetryMode = ref<OpsRetryMode>('client')
const showUpstreamList = computed(() => props.errorType === 'request')
const pinnedAccountIdInput = ref('')
const pinnedAccountId = computed<number | null>(() => {
const raw = String(pinnedAccountIdInput.value || '').trim()
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
const primaryResponseBody = computed(() => {
if (!detail.value) return ''
if (props.errorType === 'upstream') {
return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
}
return detail.value.error_body || ''
})
const title = computed(() => {
if (!props.errorId) return '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
platform?: string
account_id?: number
upstream_status_code?: number
upstream_request_id?: string
kind?: string
message?: string
detail?: string
function isUpstreamError(d: OpsErrorDetail | null): boolean {
if (!d) return false
const phase = String(d.phase || '').toLowerCase()
const owner = String(d.error_owner || '').toLowerCase()
return phase === 'upstream' && owner === 'provider'
}
const upstreamErrors = computed<UpstreamErrorEvent[]>(() => {
const raw = detail.value?.upstream_errors
if (!raw) return []
const correlatedUpstream = ref<OpsErrorDetail[]>([])
const correlatedUpstreamLoading = ref(false)
const correlatedUpstreamErrors = computed<OpsErrorDetail[]>(() => correlatedUpstream.value)
const expandedUpstreamDetailIds = ref(new Set<number>())
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim()
}
function toggleUpstreamDetail(id: number) {
const next = new Set(expandedUpstreamDetailIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedUpstreamDetailIds.value = next
}
async function fetchCorrelatedUpstreamErrors(requestErrorId: number) {
correlatedUpstreamLoading.value = true
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as UpstreamErrorEvent[]) : []
} catch {
return []
const res = await opsAPI.listRequestErrorUpstreamErrors(
requestErrorId,
{ page: 1, page_size: 100, view: 'all' },
{ include_detail: true }
)
correlatedUpstream.value = res.items || []
} catch (err) {
console.error('[OpsErrorDetailModal] Failed to load correlated upstream errors', err)
correlatedUpstream.value = []
} finally {
correlatedUpstreamLoading.value = false
}
})
}
function close() {
emit('update:show', false)
}
function prettyJSON(raw?: string): string {
if (!raw) return t('admin.ops.errorDetail.na')
if (!raw) return 'N/A'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
@@ -372,15 +267,9 @@ function prettyJSON(raw?: string): string {
async function fetchDetail(id: number) {
loading.value = true
try {
const d = await opsAPI.getErrorLogDetail(id)
const kind = props.errorType || (detail.value?.phase === 'upstream' ? 'upstream' : 'request')
const d = kind === 'upstream' ? await opsAPI.getUpstreamErrorDetail(id) : await opsAPI.getRequestErrorDetail(id)
detail.value = d
// Default pinned account from error log if present.
if (d.account_id && d.account_id > 0) {
pinnedAccountIdInput.value = String(d.account_id)
} else {
pinnedAccountIdInput.value = ''
}
} catch (err: any) {
detail.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadErrorDetail'))
@@ -397,30 +286,18 @@ watch(
return
}
if (typeof id === 'number' && id > 0) {
expandedUpstreamDetailIds.value = new Set()
fetchDetail(id)
if (props.errorType === 'request') {
fetchCorrelatedUpstreamErrors(id)
} else {
correlatedUpstream.value = []
}
}
},
{ immediate: true }
)
function openRetryConfirm(mode: OpsRetryMode) {
pendingRetryMode.value = mode
showRetryConfirm.value = true
}
const retryConfirmMessage = computed(() => {
const mode = pendingRetryMode.value
if (mode === 'upstream') {
return t('admin.ops.errorDetail.confirmRetryMessage')
}
return t('admin.ops.errorDetail.confirmRetryHint')
})
const severityClass = computed(() => {
if (!detail.value?.severity) return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
return getSeverityClass(detail.value.severity)
})
const statusClass = computed(() => {
const code = detail.value?.status_code ?? 0
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
@@ -429,29 +306,4 @@ const statusClass = computed(() => {
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
})
async function runConfirmedRetry() {
if (!props.errorId) return
const mode = pendingRetryMode.value
showRetryConfirm.value = false
retrying.value = true
try {
const req =
mode === 'upstream'
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined }
: { mode }
const res = await opsAPI.retryErrorRequest(props.errorId, req)
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
appStore.showSuccess(summary)
} catch (err: any) {
appStore.showError(err?.message || t('admin.ops.retryFailed'))
} finally {
retrying.value = false
}
}
function cancelRetry() {
showRetryConfirm.value = false
}
</script>

View File

@@ -22,23 +22,19 @@ const emit = defineEmits<{
const { t } = useI18n()
const loading = ref(false)
const rows = ref<OpsErrorLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const pageSize = ref(10)
const q = ref('')
const statusCode = ref<number | null>(null)
const statusCode = ref<number | 'other' | null>(null)
const phase = ref<string>('')
const accountIdInput = ref<string>('')
const errorOwner = ref<string>('')
const viewMode = ref<'errors' | 'excluded' | 'all'>('errors')
const accountId = computed<number | null>(() => {
const raw = String(accountIdInput.value || '').trim()
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
})
const modalTitle = computed(() => {
return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors')
@@ -48,20 +44,38 @@ const statusCodeSelectOptions = computed(() => {
const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529]
return [
{ value: null, label: t('common.all') },
...codes.map((c) => ({ value: c, label: String(c) }))
...codes.map((c) => ({ value: c, label: String(c) })),
{ value: 'other', label: t('admin.ops.errorDetails.statusCodeOther') || 'Other' }
]
})
const ownerSelectOptions = computed(() => {
return [
{ value: '', label: t('common.all') },
{ value: 'provider', label: t('admin.ops.errorDetails.owner.provider') || 'provider' },
{ value: 'client', label: t('admin.ops.errorDetails.owner.client') || 'client' },
{ value: 'platform', label: t('admin.ops.errorDetails.owner.platform') || 'platform' }
]
})
const viewModeSelectOptions = computed(() => {
return [
{ value: 'errors', label: t('admin.ops.errorDetails.viewErrors') || 'errors' },
{ value: 'excluded', label: t('admin.ops.errorDetails.viewExcluded') || 'excluded' },
{ value: 'all', label: t('common.all') }
]
})
const phaseSelectOptions = computed(() => {
const options = [
{ value: '', label: t('common.all') },
{ value: 'upstream', label: 'upstream' },
{ value: 'network', label: 'network' },
{ value: 'routing', label: 'routing' },
{ value: 'auth', label: 'auth' },
{ value: 'billing', label: 'billing' },
{ value: 'concurrency', label: 'concurrency' },
{ value: 'internal', label: 'internal' }
{ value: 'request', label: t('admin.ops.errorDetails.phase.request') || 'request' },
{ value: 'auth', label: t('admin.ops.errorDetails.phase.auth') || 'auth' },
{ value: 'routing', label: t('admin.ops.errorDetails.phase.routing') || 'routing' },
{ value: 'upstream', label: t('admin.ops.errorDetails.phase.upstream') || 'upstream' },
{ value: 'network', label: t('admin.ops.errorDetails.phase.network') || 'network' },
{ value: 'internal', label: t('admin.ops.errorDetails.phase.internal') || 'internal' }
]
return options
})
@@ -78,7 +92,8 @@ async function fetchErrorLogs() {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange
time_range: props.timeRange,
view: viewMode.value
}
const platform = String(props.platform || '').trim()
@@ -86,13 +101,19 @@ async function fetchErrorLogs() {
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
if (q.value.trim()) params.q = q.value.trim()
if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value)
if (typeof accountId.value === 'number') params.account_id = accountId.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
const res = await opsAPI.listErrorLogs(params)
const ownerVal = String(errorOwner.value || '').trim()
if (ownerVal) params.error_owner = ownerVal
const res = props.errorType === 'upstream'
? await opsAPI.listUpstreamErrors(params)
: await opsAPI.listRequestErrors(params)
rows.value = res.items || []
total.value = res.total || 0
} catch (err) {
@@ -104,21 +125,23 @@ async function fetchErrorLogs() {
}
}
function resetFilters() {
q.value = ''
statusCode.value = null
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
accountIdInput.value = ''
page.value = 1
fetchErrorLogs()
}
function resetFilters() {
q.value = ''
statusCode.value = null
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
errorOwner.value = ''
viewMode.value = 'errors'
page.value = 1
fetchErrorLogs()
}
watch(
() => props.show,
(open) => {
if (!open) return
page.value = 1
pageSize.value = 20
pageSize.value = 10
resetFilters()
}
)
@@ -154,16 +177,7 @@ watch(
)
watch(
() => [statusCode.value, phase.value] as const,
() => {
if (!props.show) return
page.value = 1
fetchErrorLogs()
}
)
watch(
() => accountId.value,
() => [statusCode.value, phase.value, errorOwner.value, viewMode.value] as const,
() => {
if (!props.show) return
page.value = 1
@@ -177,12 +191,12 @@ watch(
<div class="flex h-full min-h-0 flex-col">
<!-- Filters -->
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div class="lg:col-span-5">
<div class="grid grid-cols-8 gap-2">
<div class="col-span-2 compact-select">
<div class="relative group">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500"
class="h-3.5 w-3.5 text-gray-400 transition-colors group-focus-within:text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -193,32 +207,32 @@ watch(
<input
v-model="q"
type="text"
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
class="w-full rounded-lg border-gray-200 bg-gray-50/50 py-1.5 pl-9 pr-3 text-xs font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
/>
</div>
</div>
<div class="lg:col-span-2">
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" />
<div class="compact-select">
<Select :model-value="statusCode" :options="statusCodeSelectOptions" @update:model-value="statusCode = $event as any" />
</div>
<div class="lg:col-span-2">
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" />
<div class="compact-select">
<Select :model-value="phase" :options="phaseSelectOptions" @update:model-value="phase = String($event ?? '')" />
</div>
<div class="lg:col-span-2">
<input
v-model="accountIdInput"
type="text"
inputmode="numeric"
class="input w-full text-sm"
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
/>
<div class="compact-select">
<Select :model-value="errorOwner" :options="ownerSelectOptions" @update:model-value="errorOwner = String($event ?? '')" />
</div>
<div class="lg:col-span-1 flex items-center justify-end">
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
<div class="compact-select">
<Select :model-value="viewMode" :options="viewModeSelectOptions" @update:model-value="viewMode = $event as any" />
</div>
<div class="flex items-center justify-end">
<button type="button" class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" @click="resetFilters">
{{ t('common.reset') }}
</button>
</div>
@@ -231,18 +245,26 @@ watch(
{{ t('admin.ops.errorDetails.total') }} {{ total }}
</div>
<OpsErrorLogTable
class="min-h-0 flex-1"
:rows="rows"
:total="total"
:loading="loading"
:page="page"
:page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)"
@update:page="page = $event"
@update:pageSize="pageSize = $event"
/>
<OpsErrorLogTable
class="min-h-0 flex-1"
:rows="rows"
:total="total"
:loading="loading"
:page="page"
:page-size="pageSize"
@openErrorDetail="emit('openErrorDetail', $event)"
@update:page="page = $event"
@update:pageSize="pageSize = $event"
/>
</div>
</div>
</BaseDialog>
</template>
<style>
.compact-select .select-trigger {
@apply py-1.5 px-3 text-xs rounded-lg;
}
</style>

View File

@@ -1,55 +1,48 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-dark-900">
<!-- Loading State -->
<div v-if="loading" class="flex flex-1 items-center justify-center py-10">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
</div>
<!-- Table Container -->
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
<div class="min-h-0 flex-1 overflow-auto border-b border-gray-200 dark:border-dark-700">
<table class="w-full border-separate border-spacing-0">
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-800">
<tr>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.timeId') }}
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.time') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.context') }}
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.type') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.platform') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.model') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.group') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.user') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.status') }}
</th>
<th
scope="col"
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.message') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{ t('admin.ops.errorLog.latency') }}
</th>
<th
scope="col"
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<th class="border-b border-gray-200 px-4 py-2.5 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.action') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
<tr v-if="rows.length === 0">
<td colspan="9" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }}
</td>
</tr>
@@ -57,59 +50,83 @@
<tr
v-for="log in rows"
:key="log.id"
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900"
tabindex="0"
role="button"
class="group cursor-pointer transition-colors hover:bg-gray-50/80 dark:hover:bg-dark-800/50"
@click="emit('openErrorDetail', log.id)"
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
@keydown.space.prevent="emit('openErrorDetail', log.id)"
>
<!-- Time & ID -->
<td class="px-6 py-4">
<div class="flex flex-col gap-0.5">
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200">
<!-- Time -->
<td class="whitespace-nowrap px-4 py-2">
<el-tooltip :content="log.request_id || log.client_request_id" placement="top" :show-after="500">
<span class="font-mono text-xs font-medium text-gray-900 dark:text-gray-200">
{{ formatDateTime(log.created_at).split(' ')[1] }}
</span>
<span
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
:title="log.request_id || log.client_request_id"
>
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
</span>
</div>
</el-tooltip>
</td>
<!-- Context (Platform/Model) -->
<td class="px-6 py-4">
<div class="flex flex-col items-start gap-1.5">
<span
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ log.platform || '-' }}
</span>
<span
v-if="log.model"
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
:title="log.model"
>
<!-- Type -->
<td class="whitespace-nowrap px-4 py-2">
<span
:class="[
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getTypeBadge(log).className
]"
>
{{ getTypeBadge(log).label }}
</span>
</td>
<!-- Platform -->
<td class="whitespace-nowrap px-4 py-2">
<span class="inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-bold uppercase text-gray-600 dark:bg-dark-700 dark:text-gray-300">
{{ log.platform || '-' }}
</span>
</td>
<!-- Model -->
<td class="px-4 py-2">
<div class="max-w-[120px] truncate" :title="log.model">
<span v-if="log.model" class="font-mono text-[11px] text-gray-700 dark:text-gray-300">
{{ log.model }}
</span>
<div
v-if="log.group_id || log.account_id"
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
>
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
</div>
<span v-else class="text-xs text-gray-400">-</span>
</div>
</td>
<!-- Status & Severity -->
<td class="px-6 py-4">
<div class="flex flex-wrap items-center gap-2">
<!-- Group -->
<td class="px-4 py-2">
<el-tooltip v-if="log.group_id" :content="t('admin.ops.errorLog.id') + ' ' + log.group_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.group_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- User / Account -->
<td class="px-4 py-2">
<template v-if="isUpstreamRow(log)">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.account_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
<template v-else>
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.user_email || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
</td>
<!-- Status -->
<td class="whitespace-nowrap px-4 py-2">
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset',
getStatusClass(log.status_code)
]"
>
@@ -117,61 +134,47 @@
</span>
<span
v-if="log.severity"
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]"
:class="['rounded px-1.5 py-0.5 text-[10px] font-bold', getSeverityClass(log.severity)]"
>
{{ log.severity }}
</span>
</div>
</td>
<!-- Message -->
<td class="px-6 py-4">
<div class="max-w-md lg:max-w-2xl">
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message">
<!-- Message (Response Content) -->
<td class="px-4 py-2">
<div class="max-w-[200px]">
<p class="truncate text-[11px] font-medium text-gray-600 dark:text-gray-400" :title="log.message">
{{ formatSmartMessage(log.message) || '-' }}
</p>
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
<div v-if="log.phase" class="flex items-center gap-1">
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ log.phase }}</span>
</div>
<div v-if="log.client_ip" class="flex items-center gap-1">
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
</div>
</div>
</div>
</td>
<!-- Latency -->
<td class="px-6 py-4 text-right">
<div class="flex flex-col items-end">
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
</span>
</div>
</td>
<!-- Actions -->
<td class="px-6 py-4 text-right" @click.stop>
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)">
{{ t('admin.ops.errorLog.details') }}
</button>
<td class="whitespace-nowrap px-4 py-2 text-right" @click.stop>
<div class="flex items-center justify-end gap-3">
<button type="button" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 text-xs font-bold" @click="emit('openErrorDetail', log.id)">
{{ t('admin.ops.errorLog.details') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200, 500]"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
<!-- Pagination -->
<div class="bg-gray-50/50 dark:bg-dark-800/50">
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
:page-size-options="[10]"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</div>
</div>
</template>
@@ -184,6 +187,36 @@ import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
const { t } = useI18n()
function isUpstreamRow(log: OpsErrorLog): boolean {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
return phase === 'upstream' && owner === 'provider'
}
function getTypeBadge(log: OpsErrorLog): { label: string; className: string } {
const phase = String(log.phase || '').toLowerCase()
const owner = String(log.error_owner || '').toLowerCase()
if (isUpstreamRow(log)) {
return { label: t('admin.ops.errorLog.typeUpstream'), className: 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30' }
}
if (phase === 'request' && owner === 'client') {
return { label: t('admin.ops.errorLog.typeRequest'), className: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30' }
}
if (phase === 'auth' && owner === 'client') {
return { label: t('admin.ops.errorLog.typeAuth'), className: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-500/30' }
}
if (phase === 'routing' && owner === 'platform') {
return { label: t('admin.ops.errorLog.typeRouting'), className: 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30' }
}
if (phase === 'internal' && owner === 'platform') {
return { label: t('admin.ops.errorLog.typeInternal'), className: 'bg-gray-100 text-gray-800 ring-gray-600/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500/40' }
}
const fallback = phase || owner || t('common.unknown')
return { label: fallback, className: 'bg-gray-50 text-gray-700 ring-gray-600/10 dark:bg-dark-900 dark:text-gray-300 dark:ring-dark-700' }
}
interface Props {
rows: OpsErrorLog[]
total: number
@@ -208,14 +241,6 @@ function getStatusClass(code: number): string {
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
}
function getLatencyClass(latency: number | null): string {
if (!latency) return 'text-gray-400'
if (latency > 10000) return 'text-red-600 font-black'
if (latency > 5000) return 'text-red-500 font-bold'
if (latency > 2000) return 'text-orange-500 font-medium'
return 'text-gray-600 dark:text-gray-400'
}
function formatSmartMessage(msg: string): string {
if (!msg) return ''
@@ -231,10 +256,11 @@ function formatSmartMessage(msg: string): string {
}
}
if (msg.includes('context deadline exceeded')) return 'context 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
}
</script>
</script>

View File

@@ -38,7 +38,7 @@ const loading = ref(false)
const items = ref<OpsRequestDetail[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const pageSize = ref(10)
const close = () => emit('update:modelValue', false)
@@ -95,7 +95,7 @@ watch(
(open) => {
if (open) {
page.value = 1
pageSize.value = 20
pageSize.value = 10
fetchData()
}
}

View File

@@ -50,27 +50,22 @@ function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationR
if (thresholds) {
if (thresholds.sla_percent_min != null) {
if (!Number.isFinite(thresholds.sla_percent_min) || thresholds.sla_percent_min < 0 || thresholds.sla_percent_min > 100) {
errors.push('SLA 最低值必须在 0-100 之间')
}
}
if (thresholds.latency_p99_ms_max != null) {
if (!Number.isFinite(thresholds.latency_p99_ms_max) || thresholds.latency_p99_ms_max < 0) {
errors.push('延迟 P99 最大值必须大于或等于 0')
errors.push(t('admin.ops.runtime.validation.slaMinPercentRange'))
}
}
if (thresholds.ttft_p99_ms_max != null) {
if (!Number.isFinite(thresholds.ttft_p99_ms_max) || thresholds.ttft_p99_ms_max < 0) {
errors.push('TTFT P99 最大值必须大于或等于 0')
errors.push(t('admin.ops.runtime.validation.ttftP99MaxRange'))
}
}
if (thresholds.request_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.request_error_rate_percent_max) || thresholds.request_error_rate_percent_max < 0 || thresholds.request_error_rate_percent_max > 100) {
errors.push('请求错误率最大值必须在 0-100 之间')
errors.push(t('admin.ops.runtime.validation.requestErrorRateMaxRange'))
}
}
if (thresholds.upstream_error_rate_percent_max != null) {
if (!Number.isFinite(thresholds.upstream_error_rate_percent_max) || thresholds.upstream_error_rate_percent_max < 0 || thresholds.upstream_error_rate_percent_max > 100) {
errors.push('上游错误率最大值必须在 0-100 之间')
errors.push(t('admin.ops.runtime.validation.upstreamErrorRateMaxRange'))
}
}
}
@@ -163,7 +158,6 @@ function openAlertEditor() {
if (!draftAlert.value.thresholds) {
draftAlert.value.thresholds = {
sla_percent_min: 99.5,
latency_p99_ms_max: 2000,
ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5
@@ -335,12 +329,12 @@ onMounted(() => {
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">指标阈值配置</div>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">配置各项指标的告警阈值超出阈值的指标将在看板上以红色显示</p>
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.metricThresholds') }}</div>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.metricThresholdsHint') }}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">SLA 最低值 (%)</div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.slaMinPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.sla_percent_min"
type="number"
@@ -350,24 +344,13 @@ onMounted(() => {
class="input"
placeholder="99.5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">SLA 低于此值时将显示为红色</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.slaMinPercentHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">延迟 P99 最大值 (ms)</div>
<input
v-model.number="draftAlert.thresholds.latency_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
placeholder="2000"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">延迟 P99 高于此值时将显示为红色</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">TTFT P99 最大值 (ms)</div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.ttftP99MaxMs') }}</div>
<input
v-model.number="draftAlert.thresholds.ttft_p99_ms_max"
type="number"
@@ -376,11 +359,11 @@ onMounted(() => {
class="input"
placeholder="500"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">TTFT P99 高于此值时将显示为红色</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.ttftP99MaxMsHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">请求错误率最大值 (%)</div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.requestErrorRateMaxPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.request_error_rate_percent_max"
type="number"
@@ -390,11 +373,11 @@ onMounted(() => {
class="input"
placeholder="5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">请求错误率高于此值时将显示为红色</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.requestErrorRateMaxPercentHint') }}</p>
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">上游错误率最大值 (%)</div>
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercent') }}</div>
<input
v-model.number="draftAlert.thresholds.upstream_error_rate_percent_max"
type="number"
@@ -404,7 +387,7 @@ onMounted(() => {
class="input"
placeholder="5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">上游错误率高于此值时将显示为红色</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.upstreamErrorRateMaxPercentHint') }}</p>
</div>
</div>
</div>
@@ -424,7 +407,7 @@ onMounted(() => {
v-model="draftAlert.silencing.global_until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
placeholder="2026-01-05T00:00:00Z"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
</div>
@@ -496,7 +479,7 @@ onMounted(() => {
v-model="(entry as any).until_rfc3339"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
placeholder="2026-01-05T00:00:00Z"
/>
</div>

View File

@@ -32,7 +32,6 @@ const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 指标阈值配置
const metricThresholds = ref<OpsMetricThresholds>({
sla_percent_min: 99.5,
latency_p99_ms_max: 2000,
ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5
@@ -53,13 +52,12 @@ async function loadAllSettings() {
advancedSettings.value = advanced
// 如果后端返回了阈值,使用后端的值;否则保持默认值
if (thresholds && Object.keys(thresholds).length > 0) {
metricThresholds.value = {
sla_percent_min: thresholds.sla_percent_min ?? 99.5,
latency_p99_ms_max: thresholds.latency_p99_ms_max ?? 2000,
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
}
metricThresholds.value = {
sla_percent_min: thresholds.sla_percent_min ?? 99.5,
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
}
}
} catch (err: any) {
console.error('[OpsSettingsDialog] Failed to load settings', err)
@@ -159,19 +157,16 @@ const validation = computed(() => {
// 验证指标阈值
if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) {
errors.push('SLA最低百分比必须在0-100之间')
}
if (metricThresholds.value.latency_p99_ms_max != null && metricThresholds.value.latency_p99_ms_max < 0) {
errors.push('延迟P99最大值必须大于等于0')
errors.push(t('admin.ops.settings.validation.slaMinPercentRange'))
}
if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) {
errors.push('TTFT P99最大值必须大于等于0')
errors.push(t('admin.ops.settings.validation.ttftP99MaxRange'))
}
if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) {
errors.push('请求错误率最大值必须在0-100之间')
errors.push(t('admin.ops.settings.validation.requestErrorRateMaxRange'))
}
if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) {
errors.push('上游错误率最大值必须在0-100之间')
errors.push(t('admin.ops.settings.validation.upstreamErrorRateMaxRange'))
}
return { valid: errors.length === 0, errors }
@@ -362,17 +357,6 @@ async function saveAllSettings() {
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.latencyP99MaxMs') }}</label>
<input
v-model.number="metricThresholds.latency_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.latencyP99MaxMsHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label>
@@ -488,43 +472,63 @@ async function saveAllSettings() {
</div>
</div>
<!-- 错误过滤 -->
<!-- Error Filtering -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">错误过滤</h5>
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.errorFiltering') }}</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">忽略 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" />
</div>
</div>
<!-- 自动刷新 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">自动刷新</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.ignoreContextCanceled') }}</label>
<p class="mt-1 text-xs text-gray-500">
自动刷新仪表板数据启用后会定期拉取最新数据
{{ t('admin.ops.settings.ignoreContextCanceledHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.ignore_context_canceled" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreNoAvailableAccounts') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.ignoreNoAvailableAccountsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
</div>
</div>
<!-- Auto Refresh -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ 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">{{ 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>

View File

@@ -61,7 +61,7 @@ const chartData = computed(() => {
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
datasets: [
{
label: t('admin.ops.qps'),
label: 'QPS',
data: props.points.map((p) => p.qps ?? 0),
borderColor: colors.value.blue,
backgroundColor: colors.value.blueAlpha,
@@ -183,7 +183,7 @@ function downloadChart() {
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
</h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>QPS</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
<template v-if="!props.fullscreen">
<button