mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/aether-rust-pioneer' into codex/async-cleanup-records
# Conflicts: # apps/aether-gateway/src/maintenance/mod.rs # apps/aether-gateway/src/maintenance/runtime/runners.rs
This commit is contained in:
@@ -1,39 +1,78 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 异步任务状态
|
||||
export type AsyncTaskStatus = 'pending' | 'submitted' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
||||
export type AsyncTaskStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'retrying'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'skipped'
|
||||
| 'pending'
|
||||
| 'submitted'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
|
||||
// 异步任务类型
|
||||
export type AsyncTaskType = 'video'
|
||||
export type AsyncTaskKind = 'scheduled' | 'daemon' | 'on_demand' | 'fire_and_forget'
|
||||
export type AsyncTaskType = AsyncTaskKind | 'video'
|
||||
|
||||
// 异步任务列表项
|
||||
export interface AsyncTaskItem {
|
||||
id: string
|
||||
external_task_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
task_type: AsyncTaskType
|
||||
model: string
|
||||
prompt: string
|
||||
status: AsyncTaskStatus
|
||||
progress_percent: number
|
||||
progress_message: string | null
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
duration_seconds: number
|
||||
resolution: string
|
||||
aspect_ratio: string
|
||||
video_url: string | null
|
||||
error_code: string | null
|
||||
error_message: string | null
|
||||
poll_count: number
|
||||
max_poll_count: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
submitted_at: string | null
|
||||
export interface AsyncTaskDefinition {
|
||||
task_key: string
|
||||
kind: AsyncTaskKind
|
||||
trigger: string
|
||||
max_attempts: number
|
||||
singleton: boolean
|
||||
persist_history: boolean
|
||||
}
|
||||
|
||||
export interface AsyncTaskItem {
|
||||
id: string
|
||||
task_key?: string
|
||||
kind?: AsyncTaskKind
|
||||
trigger?: string
|
||||
task_type?: AsyncTaskType
|
||||
external_task_id?: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
model?: string
|
||||
prompt?: string
|
||||
status: AsyncTaskStatus
|
||||
attempt?: number
|
||||
max_attempts?: number
|
||||
owner_instance?: string | null
|
||||
progress_percent: number
|
||||
progress_message: string | null
|
||||
payload?: unknown
|
||||
result?: unknown
|
||||
error_message: string | null
|
||||
cancel_requested?: boolean
|
||||
created_by?: string | null
|
||||
provider_id?: string
|
||||
provider_name?: string
|
||||
duration_seconds?: number
|
||||
resolution?: string
|
||||
aspect_ratio?: string
|
||||
video_url?: string | null
|
||||
error_code?: string | null
|
||||
poll_count?: number
|
||||
max_poll_count?: number
|
||||
created_at: string
|
||||
started_at?: string | null
|
||||
updated_at?: string | null
|
||||
finished_at?: string | null
|
||||
completed_at?: string | null
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
export interface AsyncTaskEvent {
|
||||
id: string
|
||||
run_id: string
|
||||
event_type: string
|
||||
message: string
|
||||
payload: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 候选 Key 信息
|
||||
export interface CandidateKeyInfo {
|
||||
index: number
|
||||
provider_id: string
|
||||
@@ -47,7 +86,6 @@ export interface CandidateKeyInfo {
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
// 请求元数据
|
||||
export interface AsyncTaskRequestMetadata {
|
||||
candidate_keys: CandidateKeyInfo[]
|
||||
selected_key_id: string
|
||||
@@ -56,111 +94,118 @@ export interface AsyncTaskRequestMetadata {
|
||||
user_agent: string
|
||||
request_id: string
|
||||
request_headers?: Record<string, string>
|
||||
poll_raw_response?: unknown // 轮询完成时的原始响应
|
||||
billing_snapshot?: unknown // 计费快照
|
||||
poll_raw_response?: unknown
|
||||
billing_snapshot?: unknown
|
||||
}
|
||||
|
||||
// 异步任务详情
|
||||
export interface AsyncTaskDetail extends AsyncTaskItem {
|
||||
api_key_id: string
|
||||
endpoint_id: string
|
||||
key_id: string
|
||||
client_api_format: string
|
||||
provider_api_format: string
|
||||
format_converted: boolean
|
||||
original_request_body: unknown
|
||||
converted_request_body: unknown
|
||||
size: string | null
|
||||
video_urls: string[] | null
|
||||
thumbnail_url: string | null
|
||||
video_size_bytes: number | null
|
||||
video_duration_seconds: number | null // 实际视频时长(秒)
|
||||
video_expires_at: string | null
|
||||
stored_video_path: string | null
|
||||
storage_provider: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
poll_interval_seconds: number
|
||||
next_poll_at: string | null
|
||||
updated_at: string | null
|
||||
endpoint: {
|
||||
api_key_id?: string
|
||||
endpoint_id?: string
|
||||
key_id?: string
|
||||
client_api_format?: string
|
||||
provider_api_format?: string
|
||||
format_converted?: boolean
|
||||
original_request_body?: unknown
|
||||
converted_request_body?: unknown
|
||||
size?: string | null
|
||||
video_urls?: string[] | null
|
||||
thumbnail_url?: string | null
|
||||
video_size_bytes?: number | null
|
||||
video_duration_seconds?: number | null
|
||||
video_expires_at?: string | null
|
||||
stored_video_path?: string | null
|
||||
storage_provider?: string | null
|
||||
retry_count?: number
|
||||
max_retries?: number
|
||||
poll_interval_seconds?: number
|
||||
next_poll_at?: string | null
|
||||
endpoint?: {
|
||||
id: string
|
||||
base_url: string
|
||||
api_format: string
|
||||
} | null
|
||||
request_metadata: AsyncTaskRequestMetadata | null
|
||||
request_metadata?: AsyncTaskRequestMetadata | null
|
||||
}
|
||||
|
||||
// 异步任务列表响应
|
||||
export interface AsyncTaskListResponse {
|
||||
items: AsyncTaskItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
definitions?: AsyncTaskDefinition[]
|
||||
}
|
||||
|
||||
// 异步任务统计响应
|
||||
export interface AsyncTaskStatsResponse {
|
||||
total: number
|
||||
by_status: Record<AsyncTaskStatus, number>
|
||||
by_model: Record<string, number>
|
||||
today_count: number
|
||||
active_users?: number // 仅管理员
|
||||
processing_count?: number // 仅管理员
|
||||
running_count?: number
|
||||
registered_tasks?: number
|
||||
by_status: Partial<Record<AsyncTaskStatus, number>>
|
||||
by_kind?: Partial<Record<AsyncTaskKind, number>>
|
||||
by_model?: Record<string, number>
|
||||
today_count?: number
|
||||
active_users?: number
|
||||
processing_count?: number
|
||||
}
|
||||
|
||||
// 异步任务查询参数
|
||||
export interface AsyncTaskQueryParams {
|
||||
status?: AsyncTaskStatus
|
||||
kind?: AsyncTaskKind
|
||||
task_type?: AsyncTaskType
|
||||
task_key?: string
|
||||
trigger?: string
|
||||
user_id?: string
|
||||
model?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
function normalizeStatus(status: AsyncTaskStatus): AsyncTaskStatus {
|
||||
if (status === 'processing') return 'running'
|
||||
if (status === 'submitted' || status === 'pending') return 'queued'
|
||||
if (status === 'completed') return 'succeeded'
|
||||
return status
|
||||
}
|
||||
|
||||
export const asyncTasksApi = {
|
||||
/**
|
||||
* 获取异步任务列表
|
||||
*/
|
||||
async list(params: AsyncTaskQueryParams = {}): Promise<AsyncTaskListResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.status) searchParams.append('status', params.status)
|
||||
if (params.task_type) searchParams.append('task_type', params.task_type)
|
||||
if (params.user_id) searchParams.append('user_id', params.user_id)
|
||||
if (params.model) searchParams.append('model', params.model)
|
||||
if (params.status) searchParams.append('status', normalizeStatus(params.status))
|
||||
if (params.kind) searchParams.append('kind', params.kind)
|
||||
if (params.task_type && params.task_type !== 'video') searchParams.append('kind', params.task_type)
|
||||
if (params.task_key) searchParams.append('task_key', params.task_key)
|
||||
if (params.trigger) searchParams.append('trigger', params.trigger)
|
||||
if (params.page) searchParams.append('page', params.page.toString())
|
||||
if (params.page_size) searchParams.append('page_size', params.page_size.toString())
|
||||
|
||||
const query = searchParams.toString()
|
||||
// 后端 API 路径保持不变,前端抽象为异步任务
|
||||
const url = query ? `/api/admin/video-tasks?${query}` : '/api/admin/video-tasks'
|
||||
const url = query ? `/api/admin/tasks?${query}` : '/api/admin/tasks'
|
||||
const response = await apiClient.get(url)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取异步任务统计
|
||||
*/
|
||||
async getStats(): Promise<AsyncTaskStatsResponse> {
|
||||
const response = await apiClient.get('/api/admin/video-tasks/stats')
|
||||
const response = await apiClient.get('/api/admin/tasks/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取异步任务详情
|
||||
*/
|
||||
async getDetail(taskId: string): Promise<AsyncTaskDetail> {
|
||||
const response = await apiClient.get(`/api/admin/video-tasks/${taskId}`)
|
||||
const response = await apiClient.get(`/api/admin/tasks/${taskId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getEvents(taskId: string): Promise<{ items: AsyncTaskEvent[] }> {
|
||||
const response = await apiClient.get(`/api/admin/tasks/${taskId}/events`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消异步任务
|
||||
*/
|
||||
async cancel(taskId: string): Promise<{ id: string; status: string; message: string }> {
|
||||
const response = await apiClient.post(`/api/admin/video-tasks/${taskId}/cancel`)
|
||||
const response = await apiClient.post(`/api/admin/tasks/${taskId}/cancel`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async trigger(taskKey: string, payload: Record<string, unknown> = {}): Promise<{ run_id: string; status: string }> {
|
||||
const response = await apiClient.post(`/api/admin/tasks/${taskKey}/trigger`, payload)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,6 +171,21 @@ export interface ApiKey {
|
||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||
}
|
||||
|
||||
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
||||
export type InstallTargetSystem = 'macos' | 'linux' | 'windows' | 'auto'
|
||||
|
||||
export interface ApiKeyInstallSession {
|
||||
install_code: string
|
||||
expires_at_unix_secs: number
|
||||
expires_in_seconds: number
|
||||
target_cli: InstallTargetCli
|
||||
target_cli_label: string
|
||||
target_system: InstallTargetSystem
|
||||
target_system_label: string
|
||||
unix_command: string
|
||||
powershell_command: string
|
||||
}
|
||||
|
||||
// 不再需要 ProviderBinding 接口
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
@@ -270,6 +285,17 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createApiKeyInstallSession(
|
||||
keyId: string,
|
||||
data: { target_cli: InstallTargetCli; target_system: InstallTargetSystem }
|
||||
): Promise<ApiKeyInstallSession> {
|
||||
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||
`/api/users/me/api-keys/${keyId}/install-sessions`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 使用统计
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
|
||||
@@ -48,9 +48,58 @@ export interface ProxyNode {
|
||||
|
||||
export interface ProxyNodeEvent {
|
||||
id: number
|
||||
event_type: 'connected' | 'disconnected' | 'error'
|
||||
event_type: string
|
||||
detail: string | null
|
||||
created_at: string
|
||||
event_metadata?: Record<string, unknown> | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export type ProxyNodeMetricsStep = '1m' | '1h'
|
||||
|
||||
export interface ProxyNodeMetricsSummary {
|
||||
samples: number
|
||||
uptime_samples: number
|
||||
uptime_ratio: number | null
|
||||
active_connections_sum: number
|
||||
active_connections_max: number
|
||||
active_connections_avg: number | null
|
||||
heartbeat_rtt_ms_sum: number
|
||||
heartbeat_rtt_ms_max: number
|
||||
heartbeat_rtt_ms_avg: number | null
|
||||
connect_errors_delta: number
|
||||
disconnects_delta: number
|
||||
error_events_delta: number
|
||||
ws_in_bytes_delta: number
|
||||
ws_out_bytes_delta: number
|
||||
ws_in_frames_delta: number
|
||||
ws_out_frames_delta: number
|
||||
}
|
||||
|
||||
export interface ProxyNodeMetricsBucket extends ProxyNodeMetricsSummary {
|
||||
node_id?: string
|
||||
bucket_start_unix_secs: number
|
||||
bucket_start: string | null
|
||||
}
|
||||
|
||||
export interface ProxyNodeMetricsResponse {
|
||||
step: ProxyNodeMetricsStep
|
||||
from: number
|
||||
to: number
|
||||
items: ProxyNodeMetricsBucket[]
|
||||
summary: ProxyNodeMetricsSummary
|
||||
}
|
||||
|
||||
export interface ProxyNodeMetricsQuery {
|
||||
from: number
|
||||
to: number
|
||||
step: ProxyNodeMetricsStep
|
||||
}
|
||||
|
||||
export interface ProxyNodeEventQuery {
|
||||
limit?: number
|
||||
from?: number
|
||||
to?: number
|
||||
event_type?: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeUpgradeRolloutProbe {
|
||||
@@ -272,8 +321,19 @@ export const proxyNodesApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listNodeEvents(nodeId: string, limit = 50): Promise<{ items: ProxyNodeEvent[] }> {
|
||||
const response = await apiClient.get<{ items: ProxyNodeEvent[] }>(`/api/admin/proxy-nodes/${nodeId}/events`, { params: { limit } })
|
||||
async listNodeMetrics(nodeId: string, params: ProxyNodeMetricsQuery): Promise<ProxyNodeMetricsResponse> {
|
||||
const response = await apiClient.get<ProxyNodeMetricsResponse>(`/api/admin/proxy-nodes/${nodeId}/metrics`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listFleetMetrics(params: ProxyNodeMetricsQuery): Promise<ProxyNodeMetricsResponse> {
|
||||
const response = await apiClient.get<ProxyNodeMetricsResponse>('/api/admin/proxy-nodes/metrics/fleet', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listNodeEvents(nodeId: string, query: ProxyNodeEventQuery | number = 50): Promise<{ items: ProxyNodeEvent[] }> {
|
||||
const params = typeof query === 'number' ? { limit: query } : query
|
||||
const response = await apiClient.get<{ items: ProxyNodeEvent[] }>(`/api/admin/proxy-nodes/${nodeId}/events`, { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ export interface CandidateRankingMetadata {
|
||||
demoted_by?: string
|
||||
}
|
||||
|
||||
export interface CandidateResponseBoundary {
|
||||
source?: string
|
||||
status_code?: number | null
|
||||
headers?: Record<string, unknown> | null
|
||||
body?: unknown
|
||||
body_ref?: string | null
|
||||
body_state?: string | null
|
||||
}
|
||||
|
||||
export interface CandidateRecord {
|
||||
id: string
|
||||
request_id: string
|
||||
@@ -46,7 +55,9 @@ export interface CandidateRecord {
|
||||
latency_ms?: number
|
||||
concurrent_requests?: number
|
||||
ranking?: CandidateRankingMetadata | null
|
||||
extra_data?: Record<string, unknown>
|
||||
extra_data?: Record<string, unknown> & {
|
||||
upstream_response?: CandidateResponseBoundary
|
||||
}
|
||||
created_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
@@ -54,6 +65,9 @@ export interface CandidateRecord {
|
||||
|
||||
export interface RequestTrace {
|
||||
request_id: string
|
||||
request_path?: string
|
||||
request_query_string?: string
|
||||
request_path_and_query?: string
|
||||
total_candidates: number
|
||||
final_status: 'success' | 'failed' | 'streaming' | 'pending' | 'cancelled'
|
||||
total_latency_ms: number
|
||||
|
||||
@@ -25,12 +25,42 @@ const applyDarkMode = (value: boolean) => {
|
||||
}
|
||||
|
||||
const getSystemPreference = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
const getThemeStorage = (): Storage | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const storage = window.localStorage
|
||||
if (!storage || typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') {
|
||||
return null
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
const readStoredTheme = (): ThemeMode | null => {
|
||||
try {
|
||||
const value = getThemeStorage()?.getItem(THEME_STORAGE_KEY)
|
||||
return value === 'dark' || value === 'light' || value === 'system' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeStoredTheme = (value: ThemeMode) => {
|
||||
try {
|
||||
getThemeStorage()?.setItem(THEME_STORAGE_KEY, value)
|
||||
} catch {
|
||||
// Ignore storage failures in restricted or test-like environments.
|
||||
}
|
||||
}
|
||||
|
||||
const updateDarkMode = () => {
|
||||
if (themeMode.value === 'system') {
|
||||
isDark.value = getSystemPreference()
|
||||
@@ -59,9 +89,7 @@ const ensureWatcher = () => {
|
||||
(value) => {
|
||||
updateDarkMode()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, value)
|
||||
}
|
||||
writeStoredTheme(value)
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
@@ -77,9 +105,9 @@ const initialize = () => {
|
||||
ensureWatcher()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null
|
||||
const storedTheme = readStoredTheme()
|
||||
|
||||
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'system') {
|
||||
if (storedTheme) {
|
||||
themeMode.value = storedTheme
|
||||
} else {
|
||||
// 兼容旧版本存储格式,旧版本直接存储 'dark' 或 'light'
|
||||
@@ -87,8 +115,10 @@ const initialize = () => {
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', handleSystemChange)
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', handleSystemChange)
|
||||
}
|
||||
}
|
||||
|
||||
updateDarkMode()
|
||||
|
||||
@@ -673,6 +673,7 @@ import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
import HorizontalRequestTimeline from '@/features/usage/components/HorizontalRequestTimeline.vue'
|
||||
import JsonContent from '@/features/usage/components/RequestDetailDrawer/JsonContent.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
|
||||
type TestEndpointOption = {
|
||||
id: string
|
||||
@@ -716,7 +717,7 @@ const traceCandidates = computed(() => props.trace?.candidates ?? [])
|
||||
const showSetup = computed(() => props.open && !props.testing && !props.result)
|
||||
const showResult = computed(() => !!props.result)
|
||||
const showTraceTimeline = computed(() => Boolean(props.requestId) && traceCandidates.value.length > 0)
|
||||
const isDark = computed(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
const { isDark } = useDarkMode()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
|
||||
@@ -215,31 +215,12 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptSchedulerInfo"
|
||||
v-if="currentAttemptRequestPathDisplay"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">调度顺位</span>
|
||||
<span class="info-value info-value-stacked">
|
||||
<code class="format-code">
|
||||
全局 {{ currentAttemptSchedulerInfo.globalPriorityLabel }}
|
||||
/ Provider {{ currentAttemptSchedulerInfo.providerPriorityLabel }}
|
||||
/ Key {{ currentAttemptSchedulerInfo.keyPriorityLabel }}
|
||||
</code>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ currentAttemptSchedulerInfo.hint }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRankingInfo"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">排序原因</span>
|
||||
<span class="info-value info-value-stacked">
|
||||
<code class="format-code">{{ currentAttemptRankingInfo.summary }}</code>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ currentAttemptRankingInfo.hint }}
|
||||
</span>
|
||||
<span class="info-label">请求路径</span>
|
||||
<span class="info-value">
|
||||
<code class="format-code request-path-code">{{ currentAttemptRequestPathDisplay }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -269,19 +250,7 @@
|
||||
<span class="info-value info-value-stacked">
|
||||
<code class="format-code">{{ currentAttemptKeyFormatsDisplay }}</code>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
同一 Key 的不同 endpoint 会分别参与候选与转换判定
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptConversionInfo"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">转换策略</span>
|
||||
<span class="info-value info-value-stacked">
|
||||
<code class="format-code">{{ currentAttemptConversionInfo.summary }}</code>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ currentAttemptConversionInfo.hint }}
|
||||
Key 声明的可用 endpoint 格式
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -462,38 +431,42 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 真实请求错误:节点级调试原因,和对客户端返回的摘要分开 -->
|
||||
<!-- 错误信息:真实上游响应合并在此处展示 -->
|
||||
<div
|
||||
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
|
||||
class="error-block"
|
||||
>
|
||||
<div class="error-type">
|
||||
真实请求错误
|
||||
<div class="error-heading">
|
||||
<span class="error-type">错误信息</span>
|
||||
<span
|
||||
v-if="currentAttemptRequestError.statusCode != null"
|
||||
class="error-status-badge"
|
||||
:class="currentAttemptRequestError.statusCode >= 400 ? 'is-error' : currentAttemptRequestError.statusCode >= 300 ? 'is-warning' : 'is-success'"
|
||||
>
|
||||
HTTP {{ currentAttemptRequestError.statusCode }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="error-msg">
|
||||
<div
|
||||
v-if="currentAttemptRequestError.message"
|
||||
class="error-msg"
|
||||
>
|
||||
{{ currentAttemptRequestError.message }}
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.meta.length > 0"
|
||||
class="error-flow-meta"
|
||||
v-if="currentAttemptRequestError.upstreamResponse"
|
||||
class="error-json"
|
||||
>
|
||||
<span
|
||||
v-for="item in currentAttemptRequestError.meta"
|
||||
:key="item"
|
||||
class="error-flow-chip"
|
||||
>{{ item }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttemptRequestError.safetyHint"
|
||||
class="error-flow-safety"
|
||||
>
|
||||
{{ currentAttemptRequestError.safetyHint }}
|
||||
<JsonContentPanel
|
||||
:data="currentAttemptRequestError.upstreamResponse"
|
||||
:is-dark="isDark"
|
||||
empty-message="无上游响应信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外数据 -->
|
||||
<details
|
||||
v-if="currentAttempt.extra_data && Object.keys(currentAttempt.extra_data).length > 0"
|
||||
v-if="currentAttemptExtraDataDisplay"
|
||||
class="extra-block"
|
||||
>
|
||||
<summary class="extra-toggle">
|
||||
@@ -501,7 +474,7 @@
|
||||
</summary>
|
||||
<JsonContentPanel
|
||||
class="extra-json-panel"
|
||||
:data="currentAttempt.extra_data"
|
||||
:data="currentAttemptExtraDataDisplay"
|
||||
:is-dark="isDark"
|
||||
empty-message="无额外信息"
|
||||
/>
|
||||
@@ -539,6 +512,7 @@ import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { resolveTimelineFinalStatus } from '../utils/status'
|
||||
import {
|
||||
buildPoolGroupVisibleAttempts,
|
||||
@@ -590,17 +564,6 @@ interface UsageData {
|
||||
}
|
||||
}
|
||||
|
||||
interface AttemptErrorFlow {
|
||||
source?: string
|
||||
statusCode?: number
|
||||
classification?: string
|
||||
decision?: string
|
||||
retryable?: boolean
|
||||
safeToExpose?: boolean
|
||||
propagation?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
requestId?: string | null
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
@@ -672,7 +635,7 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const internalTrace = ref<RequestTrace | null>(null)
|
||||
const isDark = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const { isDark } = useDarkMode()
|
||||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||||
const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
@@ -1104,65 +1067,42 @@ const readNumberField = (obj: Record<string, unknown>, key: string): number | un
|
||||
return undefined
|
||||
}
|
||||
|
||||
const readBooleanField = (obj: Record<string, unknown>, key: string): boolean | undefined => {
|
||||
const value = obj[key]
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
const hasRenderableValue = (value: unknown): boolean => {
|
||||
if (value == null) return false
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizeAttemptErrorFlow = (value: unknown): AttemptErrorFlow | null => {
|
||||
const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknown> | null => {
|
||||
const raw = extractObject(value)
|
||||
if (!raw) return null
|
||||
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
|
||||
const headers = raw.headers
|
||||
const body = raw.body
|
||||
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
|
||||
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
|
||||
|
||||
const flow: AttemptErrorFlow = {
|
||||
source: readStringField(raw, 'source'),
|
||||
statusCode: readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode'),
|
||||
classification: readStringField(raw, 'classification'),
|
||||
decision: readStringField(raw, 'decision'),
|
||||
retryable: readBooleanField(raw, 'retryable'),
|
||||
safeToExpose: readBooleanField(raw, 'safe_to_expose') ?? readBooleanField(raw, 'safeToExpose'),
|
||||
propagation: readStringField(raw, 'propagation'),
|
||||
message: readStringField(raw, 'message'),
|
||||
if (
|
||||
statusCode == null &&
|
||||
!hasRenderableValue(headers) &&
|
||||
!hasRenderableValue(body) &&
|
||||
!bodyRef &&
|
||||
!bodyState
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Object.values(flow).some(value => value !== undefined) ? flow : null
|
||||
const data: Record<string, unknown> = {}
|
||||
if (statusCode != null) data.status_code = statusCode
|
||||
if (hasRenderableValue(headers)) data.headers = headers
|
||||
if (hasRenderableValue(body)) data.body = body
|
||||
if (bodyRef) data.body_ref = bodyRef
|
||||
if (bodyState) data.body_state = bodyState
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const labelFromMap = (value: string | undefined, labels: Record<string, string>): string | undefined => {
|
||||
if (!value) return undefined
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
const formatErrorFlowSource = (value?: string): string | undefined => labelFromMap(value, {
|
||||
upstream_response: '上游响应',
|
||||
request_validation: '请求校验',
|
||||
gateway: '网关处理',
|
||||
transport: '传输层',
|
||||
scheduler: '调度层',
|
||||
})
|
||||
|
||||
const formatErrorFlowDecision = (value?: string): string | undefined => labelFromMap(value, {
|
||||
retry_next_candidate: '重试下一个候选',
|
||||
stop_local_failover: '停止本地转移',
|
||||
use_default: '默认处理',
|
||||
return_to_client: '返回客户端',
|
||||
})
|
||||
|
||||
const formatErrorFlowPropagation = (value?: string): string | undefined => labelFromMap(value, {
|
||||
suppressed: '已抑制',
|
||||
converted: '已转换',
|
||||
passthrough: '直接透传',
|
||||
local: '本地生成',
|
||||
captured: '仅采集',
|
||||
})
|
||||
|
||||
const formatErrorFlowClassification = (value?: string): string | undefined => labelFromMap(value, {
|
||||
retryable: '可重试',
|
||||
terminal: '终止',
|
||||
provider_auth: '上游认证',
|
||||
provider_quota: '上游额度',
|
||||
invalid_request: '请求无效',
|
||||
})
|
||||
|
||||
const extractStringList = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -1181,59 +1121,11 @@ const extractStringList = (value: unknown): string[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizePriorityNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value)
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.trunc(parsed)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveClientApiFormat = (attempt: CandidateRecord): string => {
|
||||
const extra = (
|
||||
attempt.extra_data && typeof attempt.extra_data === 'object' && !Array.isArray(attempt.extra_data)
|
||||
? attempt.extra_data
|
||||
: {}
|
||||
) as Record<string, unknown>
|
||||
const fromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format.trim() : ''
|
||||
if (fromExtra) return fromExtra
|
||||
if (typeof props.requestApiFormat === 'string' && props.requestApiFormat.trim()) {
|
||||
return props.requestApiFormat.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveProviderApiFormat = (attempt: CandidateRecord): string => {
|
||||
const extra = (
|
||||
attempt.extra_data && typeof attempt.extra_data === 'object' && !Array.isArray(attempt.extra_data)
|
||||
? attempt.extra_data
|
||||
: {}
|
||||
) as Record<string, unknown>
|
||||
const fromExtra = typeof extra.provider_api_format === 'string' ? extra.provider_api_format.trim() : ''
|
||||
if (fromExtra) return fromExtra
|
||||
if (typeof attempt.endpoint_name === 'string' && attempt.endpoint_name.trim()) {
|
||||
return attempt.endpoint_name.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveTransportDiagnostics = (attempt: CandidateRecord): Record<string, unknown> | null => {
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
return extractObject(extra?.transport_diagnostics)
|
||||
}
|
||||
|
||||
const resolveEndpointFormatAcceptanceConfig = (attempt: CandidateRecord): Record<string, unknown> | null => {
|
||||
const fromAttempt = extractObject(attempt.endpoint_format_acceptance_config)
|
||||
if (fromAttempt) return fromAttempt
|
||||
const transport = resolveTransportDiagnostics(attempt)
|
||||
return extractObject(transport?.endpoint_format_acceptance_config)
|
||||
}
|
||||
|
||||
const currentAttemptFormatDisplay = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return ''
|
||||
@@ -1261,120 +1153,55 @@ const currentAttemptFormatDisplay = computed(() => {
|
||||
return providerText || requestText
|
||||
})
|
||||
|
||||
const currentAttemptSchedulerInfo = computed<{
|
||||
globalPriorityLabel: string
|
||||
providerPriorityLabel: string
|
||||
keyPriorityLabel: string
|
||||
hint: string
|
||||
} | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
const normalizeQueryString = (value: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
return trimmed.startsWith('?') ? trimmed.slice(1) : trimmed
|
||||
}
|
||||
|
||||
const clientApiFormat = resolveClientApiFormat(attempt)
|
||||
const providerApiFormat = resolveProviderApiFormat(attempt)
|
||||
const providerPriority = normalizePriorityNumber(attempt.provider_priority)
|
||||
const keyInternalPriority = normalizePriorityNumber(attempt.key_internal_priority)
|
||||
const resolveRequestPathFromObject = (value: unknown): string => {
|
||||
const object = extractObject(value)
|
||||
if (!object) return ''
|
||||
|
||||
let globalPriority: number | null = null
|
||||
const globalPriorityMap = attempt.key_global_priority_by_format
|
||||
if (globalPriorityMap && typeof globalPriorityMap === 'object' && !Array.isArray(globalPriorityMap) && clientApiFormat) {
|
||||
const match = Object.entries(globalPriorityMap).find(([format]) => (
|
||||
normalizeFormatSignature(format) === normalizeFormatSignature(clientApiFormat)
|
||||
))
|
||||
globalPriority = match ? normalizePriorityNumber(match[1]) : null
|
||||
}
|
||||
|
||||
const isCrossFormat = Boolean(
|
||||
clientApiFormat &&
|
||||
providerApiFormat &&
|
||||
normalizeFormatSignature(clientApiFormat) !== normalizeFormatSignature(providerApiFormat),
|
||||
const pathWithQuery = (
|
||||
readStringField(object, 'request_path_and_query')
|
||||
|| readStringField(object, 'public_path_and_query')
|
||||
|| readStringField(object, 'path_and_query')
|
||||
|| readStringField(object, 'request_uri')
|
||||
|| readStringField(object, 'public_uri')
|
||||
)
|
||||
const keepPriorityOnConversion = attempt.provider_keep_priority_on_conversion === true
|
||||
if (pathWithQuery) return pathWithQuery
|
||||
|
||||
let hint = '顺位展示 Provider / Key 优先级;不同 endpoint 独立参与候选'
|
||||
if (globalPriority !== null) {
|
||||
hint = `当前格式 ${formatApiFormat(clientApiFormat)} 先看全局 Key 优先级;不同 endpoint 单独判定`
|
||||
}
|
||||
if (isCrossFormat) {
|
||||
hint = keepPriorityOnConversion
|
||||
? '跨格式候选已开启保持优先级;同一 Key 的不同 endpoint 独立参与'
|
||||
: '跨格式候选默认排在同格式候选之后;同一 Key 的不同 endpoint 独立参与'
|
||||
}
|
||||
const path = (
|
||||
readStringField(object, 'request_path')
|
||||
|| readStringField(object, 'public_path')
|
||||
|| readStringField(object, 'path')
|
||||
)
|
||||
if (!path) return ''
|
||||
|
||||
return {
|
||||
globalPriorityLabel: globalPriority !== null ? String(globalPriority) : '-',
|
||||
providerPriorityLabel: providerPriority !== null ? String(providerPriority) : '-',
|
||||
keyPriorityLabel: keyInternalPriority !== null ? String(keyInternalPriority) : '-',
|
||||
hint,
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeMetadataText = (value: unknown): string => {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
const query = normalizeQueryString(
|
||||
readStringField(object, 'request_query_string')
|
||||
|| readStringField(object, 'public_query_string')
|
||||
|| readStringField(object, 'query_string')
|
||||
|| readStringField(object, 'query')
|
||||
|| '',
|
||||
)
|
||||
if (!query || path.includes('?')) return path
|
||||
return `${path}?${query}`
|
||||
}
|
||||
|
||||
const formatRankingModeLabel = (value: string): string => {
|
||||
const normalized = value.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
fixed_order: '固定顺序',
|
||||
cache_affinity: '亲和性优先',
|
||||
load_balance: '负载均衡',
|
||||
}
|
||||
return labels[normalized] || value
|
||||
}
|
||||
|
||||
const formatPriorityModeLabel = (value: string): string => {
|
||||
const normalized = value.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
provider: 'Provider 优先级',
|
||||
global_key: '全局 Key 优先级',
|
||||
}
|
||||
return labels[normalized] || value
|
||||
}
|
||||
|
||||
const formatRankingReasonLabel = (value: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
cached_affinity: '缓存亲和性命中',
|
||||
local_tunnel: '本地隧道优先',
|
||||
cross_format: '跨格式降级',
|
||||
}
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
const currentAttemptRankingInfo = computed<{
|
||||
summary: string
|
||||
hint: string
|
||||
} | null>(() => {
|
||||
const currentAttemptRequestPathDisplay = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
const ranking = extractObject(attempt.ranking)
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
if (!ranking && !extra) return null
|
||||
const fromAttempt = resolveRequestPathFromObject(attempt?.extra_data)
|
||||
if (fromAttempt) return fromAttempt
|
||||
|
||||
const rankingMode = normalizeMetadataText(ranking?.mode ?? extra?.ranking_mode)
|
||||
const priorityMode = normalizeMetadataText(ranking?.priority_mode ?? extra?.priority_mode)
|
||||
const promotedBy = normalizeMetadataText(ranking?.promoted_by ?? extra?.promoted_by)
|
||||
const demotedBy = normalizeMetadataText(ranking?.demoted_by ?? extra?.demoted_by)
|
||||
const rankingIndex = normalizePriorityNumber(ranking?.index ?? extra?.ranking_index)
|
||||
const prioritySlot = normalizePriorityNumber(ranking?.priority_slot ?? extra?.priority_slot)
|
||||
if (!rankingMode && !priorityMode && !promotedBy && !demotedBy && rankingIndex === null && prioritySlot === null) {
|
||||
return null
|
||||
}
|
||||
const fromTrace = resolveRequestPathFromObject(trace.value)
|
||||
if (fromTrace) return fromTrace
|
||||
|
||||
const summaryParts: string[] = []
|
||||
if (rankingMode) summaryParts.push(formatRankingModeLabel(rankingMode))
|
||||
if (promotedBy) summaryParts.push(formatRankingReasonLabel(promotedBy))
|
||||
if (demotedBy) summaryParts.push(formatRankingReasonLabel(demotedBy))
|
||||
const fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
|
||||
if (fromRequestMetadata) return fromRequestMetadata
|
||||
|
||||
const hintParts: string[] = []
|
||||
if (rankingIndex !== null) hintParts.push(`排序 #${rankingIndex + 1}`)
|
||||
if (priorityMode) hintParts.push(formatPriorityModeLabel(priorityMode))
|
||||
if (prioritySlot !== null) hintParts.push(`槽位 ${prioritySlot}`)
|
||||
|
||||
return {
|
||||
summary: summaryParts.length > 0 ? summaryParts.join(' / ') : '排序元数据',
|
||||
hint: hintParts.length > 0 ? hintParts.join(' · ') : '候选排序由 scheduler ranking engine 生成',
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const currentAttemptKeyFormatsDisplay = computed(() => {
|
||||
@@ -1388,66 +1215,6 @@ const currentAttemptKeyFormatsDisplay = computed(() => {
|
||||
.map(format => formatApiFormat(format))
|
||||
.join(' / ')
|
||||
})
|
||||
|
||||
const currentAttemptConversionInfo = computed<{
|
||||
summary: string
|
||||
hint: string
|
||||
} | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
|
||||
const clientApiFormat = resolveClientApiFormat(attempt)
|
||||
const providerApiFormat = resolveProviderApiFormat(attempt)
|
||||
if (!clientApiFormat || !providerApiFormat) return null
|
||||
|
||||
const isCrossFormat = normalizeFormatSignature(clientApiFormat) !== normalizeFormatSignature(providerApiFormat)
|
||||
if (!isCrossFormat) {
|
||||
return {
|
||||
summary: '同格式直连',
|
||||
hint: '当前候选直接命中 endpoint 原生格式',
|
||||
}
|
||||
}
|
||||
|
||||
const transportDiagnostics = resolveTransportDiagnostics(attempt)
|
||||
const providerEnabled = attempt.provider_enable_format_conversion === true
|
||||
|| transportDiagnostics?.provider_enable_format_conversion === true
|
||||
const endpointConfig = resolveEndpointFormatAcceptanceConfig(attempt)
|
||||
const endpointRuleEnabled = endpointConfig
|
||||
? endpointConfig.enabled !== false
|
||||
: false
|
||||
const acceptFormats = extractStringList(endpointConfig?.accept_formats)
|
||||
const rejectFormats = extractStringList(endpointConfig?.reject_formats)
|
||||
const normalizedClientFormat = normalizeFormatSignature(clientApiFormat)
|
||||
const endpointAcceptsClient = acceptFormats.some(
|
||||
format => normalizeFormatSignature(format) === normalizedClientFormat,
|
||||
)
|
||||
const endpointRejectsClient = rejectFormats.some(
|
||||
format => normalizeFormatSignature(format) === normalizedClientFormat,
|
||||
)
|
||||
|
||||
let summary = '未开启格式转换'
|
||||
if (providerEnabled && endpointRuleEnabled) {
|
||||
summary = 'Provider 总开关 + Endpoint 规则'
|
||||
} else if (providerEnabled) {
|
||||
summary = 'Provider 总格式转换'
|
||||
} else if (endpointRuleEnabled) {
|
||||
summary = 'Endpoint 独立格式转换'
|
||||
}
|
||||
|
||||
let hint = '同一 Key 的不同 endpoint 会分别判定格式转换'
|
||||
if (endpointRejectsClient) {
|
||||
hint = `当前 endpoint 明确拒绝 ${formatApiFormat(clientApiFormat)}`
|
||||
} else if (endpointAcceptsClient) {
|
||||
hint = `当前 endpoint 明确接受 ${formatApiFormat(clientApiFormat)}`
|
||||
} else if (providerEnabled) {
|
||||
hint = '当前跨格式由 Provider 总开关放行'
|
||||
} else if (attempt.skip_reason === 'format_conversion_disabled') {
|
||||
hint = 'Provider 总开关关闭,且当前 endpoint 未单独放行'
|
||||
}
|
||||
|
||||
return { summary, hint }
|
||||
})
|
||||
|
||||
const currentAttemptSkipReasonDisplay = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt?.skip_reason) return ''
|
||||
@@ -1497,45 +1264,64 @@ const currentAttemptFailureDiagnostic = computed<{
|
||||
}
|
||||
})
|
||||
|
||||
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return ''
|
||||
if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) {
|
||||
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const currentAttemptRequestError = computed<{
|
||||
message: string
|
||||
meta: string[]
|
||||
safetyHint: string
|
||||
statusCode?: number
|
||||
upstreamResponse: Record<string, unknown> | null
|
||||
} | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt || attempt.status !== 'failed') return null
|
||||
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
const flow = normalizeAttemptErrorFlow(extra?.error_flow)
|
||||
const upstreamResponse = extractObject(extra?.upstream_response)
|
||||
const errorFlow = extractObject(extra?.error_flow)
|
||||
const statusCode = readNumberField(upstreamResponse ?? {}, 'status_code')
|
||||
?? readNumberField(upstreamResponse ?? {}, 'statusCode')
|
||||
?? readNumberField(errorFlow ?? {}, 'status_code')
|
||||
?? readNumberField(errorFlow ?? {}, 'statusCode')
|
||||
?? attempt.status_code
|
||||
const flowMessage = errorFlow
|
||||
? readStringField(errorFlow, 'message')
|
||||
: ''
|
||||
const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim()
|
||||
? attempt.error_message.trim()
|
||||
: ''
|
||||
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
|
||||
? attempt.error_type.trim()
|
||||
: ''
|
||||
const message = flow?.message || fallbackMessage
|
||||
if (!message && !fallbackType && !flow) return null
|
||||
|
||||
const meta = [
|
||||
flow?.statusCode != null ? `HTTP ${flow.statusCode}` : (attempt.status_code ? `HTTP ${attempt.status_code}` : ''),
|
||||
formatErrorFlowSource(flow?.source),
|
||||
formatErrorFlowClassification(flow?.classification) || fallbackType,
|
||||
formatErrorFlowDecision(flow?.decision),
|
||||
formatErrorFlowPropagation(flow?.propagation),
|
||||
flow?.retryable != null ? (flow.retryable ? '会继续重试' : '不再重试') : '',
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
const safetyHint = flow?.safeToExpose === false
|
||||
? '该错误被标记为敏感上游错误:仅在链路节点展示,不应完整返回给客户端。'
|
||||
: ''
|
||||
const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType
|
||||
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
||||
if (!message && statusCode == null && !upstreamResponseDisplay) return null
|
||||
|
||||
return {
|
||||
message: message || fallbackType || '未知错误',
|
||||
meta,
|
||||
safetyHint,
|
||||
message: upstreamResponseDisplay ? '' : (message || '未知错误'),
|
||||
statusCode,
|
||||
upstreamResponse: upstreamResponseDisplay,
|
||||
}
|
||||
})
|
||||
|
||||
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
||||
const extra = extractObject(currentAttempt.value?.extra_data)
|
||||
if (!extra) return null
|
||||
|
||||
const display = { ...extra }
|
||||
delete display.upstream_response
|
||||
delete display.error_flow
|
||||
delete display.client_response
|
||||
delete display.provider_response
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
})
|
||||
|
||||
// 计算当前尝试启用的能力标签(请求需要的能力)
|
||||
const activeCapabilities = computed(() => {
|
||||
if (!currentAttempt.value?.required_capabilities) return []
|
||||
@@ -1870,8 +1656,16 @@ const getStatusColorClass = (status: string) => {
|
||||
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
|
||||
function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||||
if (!attempt) return 'available'
|
||||
const code = attempt.status_code
|
||||
const isTerminalSuccessCode = typeof code === 'number' && code >= 200 && code < 300
|
||||
|
||||
if (attempt.status === 'success') {
|
||||
if (typeof code === 'number' && !isTerminalSuccessCode) {
|
||||
return 'failed'
|
||||
}
|
||||
return 'success'
|
||||
}
|
||||
if (
|
||||
attempt.status === 'success' ||
|
||||
attempt.status === 'failed' ||
|
||||
attempt.status === 'cancelled' ||
|
||||
attempt.status === 'skipped' ||
|
||||
@@ -1890,10 +1684,9 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||||
if (isExplicitPending || isImplicitPending) {
|
||||
return 'pending'
|
||||
}
|
||||
const code = attempt.status_code
|
||||
if (typeof code === 'number') {
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400) return 'failed'
|
||||
if (isTerminalSuccessCode) return 'success'
|
||||
if (code >= 300) return 'failed'
|
||||
}
|
||||
return attempt.status
|
||||
}
|
||||
@@ -2695,50 +2488,66 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.error-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.error-status-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.error-status-badge.is-success {
|
||||
color: #166534;
|
||||
background: #22c55e18;
|
||||
}
|
||||
|
||||
.error-status-badge.is-warning {
|
||||
color: #92400e;
|
||||
background: #f59e0b1f;
|
||||
}
|
||||
|
||||
.error-status-badge.is-error {
|
||||
color: #991b1b;
|
||||
background: #ef44441f;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 0.85rem;
|
||||
color: #dc2626;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.error-flow-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem;
|
||||
.error-json {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.error-flow-chip {
|
||||
padding: 0.125rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #ef444414;
|
||||
border: 1px solid #ef44442e;
|
||||
color: #991b1b;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
.dark .error-status-badge.is-success {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.error-flow-safety {
|
||||
margin-top: 0.625rem;
|
||||
color: #991b1b;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
.dark .error-status-badge.is-warning {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.dark .error-flow-chip {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.dark .error-flow-safety {
|
||||
.dark .error-status-badge.is-error {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
|
||||
@@ -459,55 +459,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误域卡片:保持上游响应与客户端响应两个边界可对照 -->
|
||||
<div
|
||||
v-if="hasVisibleErrorCards"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
class="grid gap-3"
|
||||
:class="visibleErrorCardCount > 1 ? 'lg:grid-cols-2' : 'grid-cols-1'"
|
||||
>
|
||||
<Card
|
||||
v-if="displayClientErrorMessage"
|
||||
class="border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h4 class="text-sm font-semibold text-amber-700 dark:text-amber-300 mb-2">
|
||||
返回客户端错误
|
||||
</h4>
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-3 space-y-1">
|
||||
<p class="text-sm text-amber-900 dark:text-amber-200">
|
||||
{{ displayClientErrorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
v-if="normalizedUpstreamError"
|
||||
class="border-orange-200 dark:border-orange-800"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h4 class="text-sm font-semibold text-orange-700 dark:text-orange-300 mb-2">
|
||||
上游响应错误
|
||||
</h4>
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 space-y-1">
|
||||
<p class="text-sm text-orange-900 dark:text-orange-200">
|
||||
{{ normalizedUpstreamError.message }}
|
||||
</p>
|
||||
<p
|
||||
v-if="formatErrorDomainMeta(normalizedUpstreamError)"
|
||||
class="text-xs text-orange-800/70 dark:text-orange-200/70 font-mono"
|
||||
>
|
||||
{{ formatErrorDomainMeta(normalizedUpstreamError) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs 区域 -->
|
||||
<Card>
|
||||
<div class="p-3 sm:p-4">
|
||||
@@ -743,6 +694,7 @@ import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
@@ -750,7 +702,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -832,42 +784,11 @@ type PricingTierLike = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
type NormalizedErrorDomain = {
|
||||
source?: string | null
|
||||
status_code?: number | null
|
||||
type?: string | null
|
||||
message: string
|
||||
code?: string | number | null
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as JsonRecord
|
||||
}
|
||||
|
||||
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
|
||||
if (!domain || typeof domain !== 'object') return null
|
||||
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
|
||||
if (!message) return null
|
||||
return {
|
||||
source: domain.source ?? null,
|
||||
status_code: domain.status_code ?? null,
|
||||
type: domain.type ?? null,
|
||||
message,
|
||||
code: domain.code ?? null,
|
||||
category: domain.category ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
|
||||
const parts: string[] = []
|
||||
if (domain.status_code != null) parts.push(`HTTP ${domain.status_code}`)
|
||||
if (domain.type) parts.push(domain.type)
|
||||
if (domain.source) parts.push(`source=${domain.source}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function handleTraceState(state: { loaded: boolean, hasTrace: boolean }) {
|
||||
timelineLoaded.value = state.loaded
|
||||
timelineHasTrace.value = state.hasTrace
|
||||
@@ -987,10 +908,7 @@ watch(activeTab, (newTab) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 检测暗色模式
|
||||
const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
const { isDark } = useDarkMode()
|
||||
|
||||
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
|
||||
const meta = detail.value?.metadata
|
||||
@@ -1021,27 +939,6 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
return Object.keys(merged).length > 0 ? merged : null
|
||||
})
|
||||
|
||||
const normalizedClientError = computed(() =>
|
||||
normalizeErrorDomain(detail.value?.errors?.client_error ?? detail.value?.client_error),
|
||||
)
|
||||
|
||||
const normalizedUpstreamError = computed(() =>
|
||||
normalizeErrorDomain(detail.value?.errors?.upstream_error ?? detail.value?.upstream_error),
|
||||
)
|
||||
|
||||
const displayClientErrorMessage = computed(() =>
|
||||
normalizedClientError.value?.message ?? '',
|
||||
)
|
||||
|
||||
const hasVisibleErrorCards = computed(() =>
|
||||
Boolean(displayClientErrorMessage.value || normalizedUpstreamError.value),
|
||||
)
|
||||
|
||||
const visibleErrorCardCount = computed(() =>
|
||||
(displayClientErrorMessage.value ? 1 : 0)
|
||||
+ (normalizedUpstreamError.value ? 1 : 0),
|
||||
)
|
||||
|
||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||
asRecord(detail.value?.settlement ?? null),
|
||||
)
|
||||
|
||||
@@ -45,8 +45,14 @@ vi.mock('../JsonContentPanel.vue', async () => {
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'JsonContentPanelStub',
|
||||
setup() {
|
||||
return () => h('div')
|
||||
props: {
|
||||
data: {
|
||||
type: null,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('pre', JSON.stringify(props.data))
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -99,12 +105,16 @@ function buildTrace(candidates: CandidateRecord[]): RequestTrace {
|
||||
}
|
||||
}
|
||||
|
||||
function mountTimeline(traceData: RequestTrace) {
|
||||
function mountTimeline(
|
||||
traceData: RequestTrace,
|
||||
extraProps: Record<string, unknown> = {},
|
||||
) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(HorizontalRequestTimeline, {
|
||||
requestId: traceData.request_id,
|
||||
traceData,
|
||||
...extraProps,
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
@@ -269,4 +279,129 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(nodeDots[0].classList.contains('status-success')).toBe(false)
|
||||
expect(nodeDots[1].classList.contains('status-success')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats 3xx terminal responses as failed for node display', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-redirect',
|
||||
provider_id: 'provider-redirect',
|
||||
provider_name: 'Provider Redirect',
|
||||
key_id: 'key-redirect',
|
||||
key_name: 'Redirect Key',
|
||||
candidate_index: 0,
|
||||
status: 'success',
|
||||
status_code: 302,
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
const nodeDot = root.querySelector<HTMLElement>('.node-dot')
|
||||
expect(nodeDot?.classList.contains('status-failed')).toBe(true)
|
||||
expect(nodeDot?.classList.contains('status-success')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows request path from request metadata', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-path',
|
||||
provider_id: 'provider-path',
|
||||
provider_name: 'Provider Path',
|
||||
key_id: 'key-path',
|
||||
key_name: 'Path Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace, {
|
||||
requestMetadata: {
|
||||
request_path: '/v1beta/models/gemini-2.5-pro:generateContent',
|
||||
request_query_string: 'alt=sse',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('请求路径')
|
||||
const requestPathCode = root.querySelector<HTMLElement>('.request-path-code')
|
||||
expect(requestPathCode?.textContent).toContain('/v1beta/models/gemini-2.5-pro:generateContent?alt=sse')
|
||||
})
|
||||
|
||||
it('shows request path from trace payload', async () => {
|
||||
const trace: RequestTrace = {
|
||||
...buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-trace-path',
|
||||
provider_id: 'provider-path',
|
||||
provider_name: 'Provider Path',
|
||||
key_id: 'key-path',
|
||||
key_name: 'Path Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
}),
|
||||
]),
|
||||
request_path: '/v1/images/generations',
|
||||
}
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('请求路径')
|
||||
const requestPathCode = root.querySelector<HTMLElement>('.request-path-code')
|
||||
expect(requestPathCode?.textContent).toContain('/v1/images/generations')
|
||||
})
|
||||
|
||||
it('shows upstream response JSON inside the error block on trace nodes', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-upstream-response',
|
||||
provider_id: 'provider-upstream',
|
||||
provider_name: 'Provider Upstream',
|
||||
key_id: 'key-upstream',
|
||||
key_name: 'Upstream Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
error_message: 'execution runtime stream returned non-success status 302',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 302,
|
||||
headers: { location: '/' },
|
||||
},
|
||||
error_flow: {
|
||||
source: 'upstream_response',
|
||||
status_code: 302,
|
||||
classification: 'use_default',
|
||||
decision: 'use_default',
|
||||
propagation: 'none',
|
||||
retryable: false,
|
||||
safe_to_expose: false,
|
||||
message: 'execution runtime stream returned non-success status 302',
|
||||
},
|
||||
client_response: {
|
||||
status_code: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('HTTP 302')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"status_code":302')
|
||||
expect(root.querySelector('.error-block .error-json')?.textContent).toContain('"headers"')
|
||||
expect(root.textContent).not.toContain('上游真实响应')
|
||||
expect(root.textContent).not.toContain('execution runtime stream returned non-success status 302')
|
||||
expect(root.textContent).not.toContain('真实请求错误')
|
||||
expect(root.textContent).not.toContain('返回客户端响应')
|
||||
expect(root.textContent).not.toContain('上游响应')
|
||||
expect(root.textContent).not.toContain('默认处理')
|
||||
expect(root.textContent).not.toContain('none')
|
||||
expect(root.textContent).not.toContain('不再重试')
|
||||
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,14 @@ describe('usage status helpers', () => {
|
||||
})).toBe('failed')
|
||||
})
|
||||
|
||||
it('downgrades terminal success to failed when status code is 3xx', () => {
|
||||
expect(resolveTimelineFinalStatus({
|
||||
traceFinalStatus: 'success',
|
||||
requestStatus: 'completed',
|
||||
statusCode: 302,
|
||||
})).toBe('failed')
|
||||
})
|
||||
|
||||
it('falls back to request lifecycle status when status code and trace are missing', () => {
|
||||
expect(resolveTimelineFinalStatus({
|
||||
requestStatus: 'failed',
|
||||
@@ -189,6 +197,9 @@ describe('usage status helpers', () => {
|
||||
expect(resolveTimelineFinalStatus({
|
||||
statusCode: 200,
|
||||
})).toBe('success')
|
||||
expect(resolveTimelineFinalStatus({
|
||||
statusCode: 302,
|
||||
})).toBe('failed')
|
||||
expect(resolveTimelineFinalStatus({
|
||||
statusCode: 503,
|
||||
})).toBe('failed')
|
||||
|
||||
@@ -157,7 +157,7 @@ function hasTerminalSuccessStatusCode(
|
||||
): boolean {
|
||||
return typeof record.status_code === 'number' &&
|
||||
record.status_code >= 200 &&
|
||||
record.status_code < 400
|
||||
record.status_code < 300
|
||||
}
|
||||
|
||||
export function isUsageRecordFailed(
|
||||
@@ -272,18 +272,28 @@ export function resolveTimelineFinalStatus(params: {
|
||||
requestStatus?: RequestStatusLike
|
||||
statusCode?: number
|
||||
}): TimelineFinalStatus {
|
||||
const hasTerminalSuccessStatusCode = typeof params.statusCode === 'number'
|
||||
? params.statusCode >= 200 && params.statusCode < 300
|
||||
: undefined
|
||||
|
||||
const requestStatus = mapRequestStatusToTimelineStatus(params.requestStatus)
|
||||
if (requestStatus === 'success' || requestStatus === 'failed' || requestStatus === 'cancelled') {
|
||||
if (requestStatus === 'success' && hasTerminalSuccessStatusCode === false) {
|
||||
return 'failed'
|
||||
}
|
||||
return requestStatus
|
||||
}
|
||||
|
||||
const traceStatus = normalizeTimelineFinalStatus(params.traceFinalStatus)
|
||||
if (traceStatus === 'success' || traceStatus === 'failed' || traceStatus === 'cancelled') {
|
||||
if (traceStatus === 'success' && hasTerminalSuccessStatusCode === false) {
|
||||
return 'failed'
|
||||
}
|
||||
return traceStatus
|
||||
}
|
||||
|
||||
if (typeof params.statusCode === 'number') {
|
||||
return params.statusCode >= 200 && params.statusCode < 400 ? 'success' : 'failed'
|
||||
if (hasTerminalSuccessStatusCode !== undefined) {
|
||||
return hasTerminalSuccessStatusCode ? 'success' : 'failed'
|
||||
}
|
||||
|
||||
if (params.hasPendingCandidates) {
|
||||
|
||||
@@ -908,7 +908,10 @@ export const MOCK_SYSTEM_CONFIGS = [
|
||||
{ key: 'cache_enabled', value: true, description: '是否启用缓存' },
|
||||
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL(秒)' },
|
||||
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
|
||||
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' }
|
||||
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
|
||||
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
|
||||
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
|
||||
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
|
||||
]
|
||||
|
||||
// ========== API 格式 ==========
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Loader2
|
||||
class="w-5 h-5 text-blue-500"
|
||||
:class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }"
|
||||
:class="{ 'animate-spin': runningCount > 0 }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}
|
||||
{{ runningCount || '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
处理中
|
||||
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.by_status?.completed ?? '-' }}
|
||||
{{ stats?.by_status?.succeeded ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已完成
|
||||
@@ -69,10 +69,10 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.today_count ?? '-' }}
|
||||
{{ stats?.registered_tasks ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
今日任务
|
||||
已注册
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,14 +102,17 @@
|
||||
<SelectItem value="all">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="submitted">
|
||||
已提交
|
||||
<SelectItem value="queued">
|
||||
排队中
|
||||
</SelectItem>
|
||||
<SelectItem value="processing">
|
||||
处理中
|
||||
<SelectItem value="running">
|
||||
运行中
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
已完成
|
||||
<SelectItem value="retrying">
|
||||
重试中
|
||||
</SelectItem>
|
||||
<SelectItem value="succeeded">
|
||||
成功
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
失败
|
||||
@@ -117,13 +120,16 @@
|
||||
<SelectItem value="cancelled">
|
||||
已取消
|
||||
</SelectItem>
|
||||
<SelectItem value="skipped">
|
||||
已跳过
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- 模型筛选 -->
|
||||
<Input
|
||||
v-model="filterModel"
|
||||
type="text"
|
||||
placeholder="模型..."
|
||||
placeholder="任务 Key..."
|
||||
class="w-32 h-8 text-xs"
|
||||
/>
|
||||
<!-- 刷新按钮 -->
|
||||
@@ -207,13 +213,13 @@
|
||||
v-if="isVideoTask(task)"
|
||||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<span class="font-medium text-sm truncate">{{ task.model }}</span>
|
||||
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-muted-foreground truncate max-w-[280px]"
|
||||
:title="task.prompt"
|
||||
:title="displayTaskDescription(task)"
|
||||
>
|
||||
{{ task.prompt }}
|
||||
{{ displayTaskDescription(task) }}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -229,7 +235,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Server class="w-3 h-3" />
|
||||
<span class="truncate max-w-[100px]">{{ task.provider_name }}</span>
|
||||
<span class="truncate max-w-[100px]">{{ displayTaskSource(task) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -243,7 +249,7 @@
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</Badge>
|
||||
<div
|
||||
v-if="task.progress_percent > 0 && task.status === 'processing'"
|
||||
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -262,11 +268,11 @@
|
||||
<TableCell>
|
||||
<div class="text-xs space-y-0.5 text-muted-foreground">
|
||||
<div
|
||||
v-if="task.duration_seconds"
|
||||
v-if="task.duration_seconds || task.attempt"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Timer class="w-3 h-3" />
|
||||
<span>{{ task.duration_seconds }}s</span>
|
||||
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
||||
</div>
|
||||
<div v-if="task.resolution">
|
||||
{{ task.resolution }}
|
||||
@@ -284,11 +290,11 @@
|
||||
<span>{{ formatDate(task.created_at) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.completed_at"
|
||||
v-if="finishTime(task)"
|
||||
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3" />
|
||||
<span>{{ formatDate(task.completed_at) }}</span>
|
||||
<span>{{ formatDate(finishTime(task)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -305,6 +311,7 @@
|
||||
<Eye class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isVideoTask(task)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@@ -337,7 +344,7 @@
|
||||
v-if="isVideoTask(task)"
|
||||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<span class="font-medium text-sm truncate">{{ task.model }}</span>
|
||||
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="getStatusVariant(task.status)"
|
||||
@@ -349,7 +356,7 @@
|
||||
|
||||
<!-- 进度条(如果有) -->
|
||||
<div
|
||||
v-if="task.progress_percent > 0 && task.status === 'processing'"
|
||||
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -365,7 +372,7 @@
|
||||
|
||||
<!-- Prompt -->
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
{{ task.prompt }}
|
||||
{{ displayTaskDescription(task) }}
|
||||
</p>
|
||||
|
||||
<!-- 信息网格 -->
|
||||
@@ -379,24 +386,25 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Server class="w-3 h-3" />
|
||||
<span class="truncate">{{ task.provider_name }}</span>
|
||||
<span class="truncate">{{ displayTaskSource(task) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Clock class="w-3 h-3" />
|
||||
<span>{{ formatDate(task.created_at) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.duration_seconds"
|
||||
v-if="task.duration_seconds || task.attempt"
|
||||
class="flex items-center gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<Timer class="w-3 h-3" />
|
||||
<span>{{ task.duration_seconds }}s</span>
|
||||
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
v-if="isVideoTask(task)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@@ -459,7 +467,7 @@
|
||||
v-if="isVideoTask(selectedTask)"
|
||||
class="w-3.5 h-3.5 mr-1"
|
||||
/>
|
||||
<span>{{ selectedTask.model }}</span>
|
||||
<span>{{ displayTaskName(selectedTask) }}</span>
|
||||
</div>
|
||||
<Badge :variant="getStatusVariant(selectedTask.status)">
|
||||
{{ getStatusLabel(selectedTask.status) }}
|
||||
@@ -503,11 +511,11 @@
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
</template>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>Provider: {{ selectedTask.provider_name }}</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'"
|
||||
v-if="selectedTask.progress_percent > 0 && isRunningStatus(selectedTask.status)"
|
||||
class="mt-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -641,7 +649,7 @@
|
||||
|
||||
<!-- 任务完成但无视频 -->
|
||||
<div
|
||||
v-else-if="selectedTask.status === 'completed'"
|
||||
v-else-if="isSucceededStatus(selectedTask.status) && isVideoTask(selectedTask)"
|
||||
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
|
||||
>
|
||||
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
|
||||
@@ -660,14 +668,14 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="copyToClipboard(selectedTask.prompt)"
|
||||
@click="copyToClipboard(displayTaskDescription(selectedTask))"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 bg-muted/50 rounded-lg border border-border/60 text-sm whitespace-pre-wrap break-words max-h-32 overflow-y-auto leading-relaxed">
|
||||
{{ selectedTask.prompt }}
|
||||
{{ displayTaskDescription(selectedTask) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -738,7 +746,7 @@
|
||||
轮询
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}
|
||||
{{ selectedTask.poll_count ?? selectedTask.attempt ?? 0 }} / {{ selectedTask.max_poll_count ?? selectedTask.max_attempts ?? 1 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted/30 rounded-lg">
|
||||
@@ -746,7 +754,7 @@
|
||||
重试
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}
|
||||
{{ selectedTask.retry_count ?? 0 }} / {{ selectedTask.max_retries ?? selectedTask.max_attempts ?? 1 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted/30 rounded-lg">
|
||||
@@ -754,7 +762,7 @@
|
||||
轮询间隔
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.poll_interval_seconds }}s
|
||||
{{ selectedTask.poll_interval_seconds ? `${selectedTask.poll_interval_seconds}s` : selectedTask.trigger ?? '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -780,13 +788,13 @@
|
||||
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
|
||||
<span class="time-arrow-container">
|
||||
<span
|
||||
v-if="selectedTask.completed_at"
|
||||
v-if="finishTime(selectedTask)"
|
||||
class="time-duration"
|
||||
>+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
|
||||
>+{{ calcDuration(selectedTask.created_at, finishTime(selectedTask) || selectedTask.created_at) }}</span>
|
||||
<span class="time-arrow">→</span>
|
||||
</span>
|
||||
<template v-if="selectedTask.completed_at">
|
||||
<span>{{ formatTimeWithMs(selectedTask.completed_at) }}</span>
|
||||
<template v-if="finishTime(selectedTask)">
|
||||
<span>{{ formatTimeWithMs(finishTime(selectedTask)) }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
@@ -917,10 +925,47 @@ let overviewRefreshInFlight = false
|
||||
// 使用记录详情抽屉状态
|
||||
const usageDetailOpen = ref(false)
|
||||
const usageRequestId = ref<string | null>(null)
|
||||
const runningCount = computed(() => {
|
||||
return stats.value?.running_count
|
||||
?? stats.value?.processing_count
|
||||
?? stats.value?.by_status?.running
|
||||
?? stats.value?.by_status?.processing
|
||||
?? 0
|
||||
})
|
||||
|
||||
// 判断是否为视频任务
|
||||
function isVideoTask(task: AsyncTaskItem): boolean {
|
||||
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds
|
||||
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds || task.task_key === 'video.task.poller'
|
||||
}
|
||||
|
||||
function displayTaskName(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||||
return task.model || task.task_key || task.id
|
||||
}
|
||||
|
||||
function displayTaskDescription(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||||
if (task.prompt) return task.prompt
|
||||
if (task.progress_message) return task.progress_message
|
||||
if (task.error_message) return task.error_message
|
||||
if (task.payload) return formatJson(task.payload)
|
||||
return task.trigger || task.kind || '-'
|
||||
}
|
||||
|
||||
function displayTaskSource(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||||
if (task.provider_name) return `Provider: ${task.provider_name}`
|
||||
if (task.kind || task.trigger) return `${task.kind ?? 'task'} / ${task.trigger ?? '-'}`
|
||||
return task.owner_instance || '-'
|
||||
}
|
||||
|
||||
function finishTime(task: AsyncTaskItem | AsyncTaskDetail): string | null {
|
||||
return task.finished_at || task.completed_at || null
|
||||
}
|
||||
|
||||
function isRunningStatus(status: string): boolean {
|
||||
return ['running', 'retrying', 'processing', 'submitted', 'pending', 'queued'].includes(status)
|
||||
}
|
||||
|
||||
function isSucceededStatus(status: string): boolean {
|
||||
return status === 'succeeded' || status === 'completed'
|
||||
}
|
||||
|
||||
// 获取任务列表
|
||||
@@ -929,7 +974,7 @@ async function fetchTasks() {
|
||||
try {
|
||||
const response = await asyncTasksApi.list({
|
||||
status: filterStatus.value !== 'all' ? filterStatus.value as AsyncTaskStatus : undefined,
|
||||
model: filterModel.value || undefined,
|
||||
task_key: filterModel.value || undefined,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
@@ -1085,6 +1130,7 @@ async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
||||
// 状态相关
|
||||
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return 'default'
|
||||
case 'failed':
|
||||
@@ -1101,16 +1147,20 @@ function getStatusLabel(status: string): string {
|
||||
pending: '待处理',
|
||||
submitted: '已提交',
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
retrying: '重试中',
|
||||
processing: '处理中',
|
||||
succeeded: '成功',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
skipped: '已跳过',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function canCancel(status: string): boolean {
|
||||
return ['pending', 'submitted', 'queued', 'processing'].includes(status)
|
||||
return ['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(status)
|
||||
}
|
||||
|
||||
// 格式化日期(简短格式,用于表格列表)
|
||||
@@ -1229,7 +1279,7 @@ watch(filterModel, () => {
|
||||
// 检查是否有进行中的任务
|
||||
const hasProcessingTasks = computed(() => {
|
||||
return tasks.value.some(t =>
|
||||
['pending', 'submitted', 'queued', 'processing'].includes(t.status)
|
||||
['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(t.status)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[28px] min-w-[28px] max-w-[28px] h-12 p-0 pl-2" />
|
||||
<TableHead class="w-[160px] h-12 font-semibold">
|
||||
名称
|
||||
</TableHead>
|
||||
@@ -173,141 +174,177 @@
|
||||
<TableHead class="w-[160px] h-12 font-semibold">
|
||||
最后心跳
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">
|
||||
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
<template
|
||||
v-for="node in paginatedNodes"
|
||||
:key="node.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
<TableRow
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
:class="isNodeExpanded(node.id) ? 'bg-muted/20' : ''"
|
||||
>
|
||||
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
|
||||
@click="toggleNodeDetails(node)"
|
||||
>
|
||||
手动
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="node.tunnel_mode"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
Tunnel
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="nodeSchedulingBadge(node)"
|
||||
:variant="nodeSchedulingBadge(node)!.variant"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ nodeSchedulingBadge(node)!.label }}
|
||||
</Badge>
|
||||
<HardwareTooltip :node="node" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="statusVariant(node.status)"
|
||||
:title="statusTitle(node)"
|
||||
class="font-medium px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ statusLabel(node) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span
|
||||
class="text-sm tabular-nums"
|
||||
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
||||
>{{ formatFailureRate(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
|
||||
:disabled="testingNodes.has(node.id)"
|
||||
@click="handleTest(node)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingNodes.has(node.id)"
|
||||
class="h-4 w-4 animate-spin"
|
||||
<ChevronDown
|
||||
v-if="isNodeExpanded(node.id)"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<Activity
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="handleEdit(node)"
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
手动
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="node.tunnel_mode"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
Tunnel
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="nodeSchedulingBadge(node)"
|
||||
:variant="nodeSchedulingBadge(node)!.variant"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ nodeSchedulingBadge(node)!.label }}
|
||||
</Badge>
|
||||
<HardwareTooltip :node="node" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="statusVariant(node.status)"
|
||||
:title="statusTitle(node)"
|
||||
class="font-medium px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="远程配置"
|
||||
@click="handleConfig(node)"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="连接事件"
|
||||
@click="handleViewEvents(node)"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{{ statusLabel(node) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span
|
||||
class="text-sm tabular-nums"
|
||||
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
||||
>{{ formatFailureRate(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
|
||||
:disabled="testingNodes.has(node.id)"
|
||||
@click="handleTest(node)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingNodes.has(node.id)"
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Activity
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="编辑"
|
||||
@click="handleEdit(node)"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="远程配置"
|
||||
@click="handleConfig(node)"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="连接事件"
|
||||
@click="handleViewEvents(node)"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-if="isNodeExpanded(node.id)"
|
||||
class="border-b border-border/40 hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
colspan="11"
|
||||
class="p-0"
|
||||
>
|
||||
<ProxyNodeDataPanel
|
||||
:node="node"
|
||||
:state="nodeDetails[node.id]"
|
||||
@refresh="loadNodeDetails(node)"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableRow v-if="paginatedNodes.length === 0">
|
||||
<TableCell
|
||||
colspan="10"
|
||||
colspan="11"
|
||||
class="py-12 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
{{ store.loading ? '加载中...' : '暂无代理节点' }}
|
||||
@@ -327,6 +364,21 @@
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 shrink-0"
|
||||
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
|
||||
@click="toggleNodeDetails(node)"
|
||||
>
|
||||
<ChevronDown
|
||||
v-if="isNodeExpanded(node.id)"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
@@ -390,7 +442,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -439,6 +491,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isNodeExpanded(node.id)"
|
||||
class="mt-4 -mx-4 sm:-mx-5"
|
||||
>
|
||||
<ProxyNodeDataPanel
|
||||
:node="node"
|
||||
:state="nodeDetails[node.id]"
|
||||
@refresh="loadNodeDetails(node)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="paginatedNodes.length === 0"
|
||||
@@ -779,6 +841,7 @@ import {
|
||||
proxyNodesApi,
|
||||
type ProxyNode,
|
||||
type ProxyNodeEvent,
|
||||
type ProxyNodeMetricsResponse,
|
||||
type ProxyNodeRemoteConfig,
|
||||
type ProxyNodeSchedulingState,
|
||||
type ProxyNodeTestResult,
|
||||
@@ -808,10 +871,11 @@ import {
|
||||
Dialog,
|
||||
} from '@/components/ui'
|
||||
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History } from 'lucide-vue-next'
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import HardwareTooltip from './components/HardwareTooltip.vue'
|
||||
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
|
||||
|
||||
const { success, error: toastError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -860,6 +924,18 @@ const eventsNode = ref<ProxyNode | null>(null)
|
||||
const nodeEvents = ref<ProxyNodeEvent[]>([])
|
||||
const loadingEvents = ref(false)
|
||||
|
||||
interface ProxyNodeDetailState {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
node: ProxyNode | null
|
||||
metrics: ProxyNodeMetricsResponse | null
|
||||
events: ProxyNodeEvent[]
|
||||
loadedAt: number | null
|
||||
}
|
||||
|
||||
const expandedNodeIds = ref(new Set<string>())
|
||||
const nodeDetails = ref<Record<string, ProxyNodeDetailState>>({})
|
||||
|
||||
// 测试连通性
|
||||
const testingNodes = ref(new Set<string>())
|
||||
const testingUrl = ref(false)
|
||||
@@ -1131,12 +1207,82 @@ async function handleTest(node: ProxyNode) {
|
||||
}
|
||||
}
|
||||
|
||||
function createNodeDetailState(): ProxyNodeDetailState {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
node: null,
|
||||
metrics: null,
|
||||
events: [],
|
||||
loadedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function updateNodeDetailState(nodeId: string, patch: Partial<ProxyNodeDetailState>) {
|
||||
nodeDetails.value = {
|
||||
...nodeDetails.value,
|
||||
[nodeId]: {
|
||||
...(nodeDetails.value[nodeId] ?? createNodeDetailState()),
|
||||
...patch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeExpanded(nodeId: string) {
|
||||
return expandedNodeIds.value.has(nodeId)
|
||||
}
|
||||
|
||||
function toggleNodeDetails(node: ProxyNode) {
|
||||
const next = new Set(expandedNodeIds.value)
|
||||
if (next.has(node.id)) {
|
||||
next.delete(node.id)
|
||||
expandedNodeIds.value = next
|
||||
return
|
||||
}
|
||||
|
||||
next.add(node.id)
|
||||
expandedNodeIds.value = next
|
||||
|
||||
const detailState = nodeDetails.value[node.id]
|
||||
if (!detailState?.loadedAt && !detailState?.loading) {
|
||||
void loadNodeDetails(node)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodeDetails(node: ProxyNode) {
|
||||
updateNodeDetailState(node.id, { loading: true, error: null })
|
||||
const to = Math.floor(Date.now() / 1000)
|
||||
const from = to - 24 * 60 * 60
|
||||
const eventsFrom = to - 7 * 24 * 60 * 60
|
||||
|
||||
try {
|
||||
const [detail, metrics, events] = await Promise.all([
|
||||
proxyNodesApi.getNode(node.id),
|
||||
proxyNodesApi.listNodeMetrics(node.id, { from, to, step: '1h' }),
|
||||
proxyNodesApi.listNodeEvents(node.id, { limit: 8, from: eventsFrom, to }),
|
||||
])
|
||||
updateNodeDetailState(node.id, {
|
||||
loading: false,
|
||||
error: null,
|
||||
node: detail.node,
|
||||
metrics,
|
||||
events: events.items,
|
||||
loadedAt: Date.now(),
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
updateNodeDetailState(node.id, {
|
||||
loading: false,
|
||||
error: parseApiError(err, '加载节点数据失败'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewEvents(node: ProxyNode) {
|
||||
eventsNode.value = node
|
||||
showEventsDialog.value = true
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res = await proxyNodesApi.listNodeEvents(node.id, 50)
|
||||
const res = await proxyNodesApi.listNodeEvents(node.id, { limit: 50 })
|
||||
nodeEvents.value = res.items
|
||||
} catch (err: unknown) {
|
||||
toastError(parseApiError(err, '加载事件失败'))
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
:audit-log-retention-days="systemConfig.audit_log_retention_days"
|
||||
:request-candidates-retention-days="systemConfig.request_candidates_retention_days"
|
||||
:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size"
|
||||
:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days"
|
||||
:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days"
|
||||
:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size"
|
||||
:loading="cleanupConfigLoading"
|
||||
:has-changes="hasCleanupConfigChanges"
|
||||
@save="saveCleanupConfig"
|
||||
@@ -114,6 +117,9 @@
|
||||
@update:audit-log-retention-days="systemConfig.audit_log_retention_days = $event"
|
||||
@update:request-candidates-retention-days="systemConfig.request_candidates_retention_days = $event"
|
||||
@update:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size = $event"
|
||||
@update:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days = $event"
|
||||
@update:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days = $event"
|
||||
@update:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size = $event"
|
||||
/>
|
||||
|
||||
<!-- 定时任务 -->
|
||||
|
||||
478
frontend/src/views/admin/components/ProxyNodeDataPanel.vue
Normal file
478
frontend/src/views/admin/components/ProxyNodeDataPanel.vue
Normal file
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="px-4 sm:px-6 py-4 bg-muted/15 border-t border-border/40">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-semibold">
|
||||
节点数据
|
||||
</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
最近 24 小时
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ loadedText }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs self-start sm:self-auto"
|
||||
:disabled="state?.loading"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5 mr-1"
|
||||
:class="state?.loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="state?.loading && !state.metrics"
|
||||
class="py-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
加载节点数据...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="state?.error"
|
||||
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive flex items-start gap-2"
|
||||
>
|
||||
<AlertTriangle class="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{{ state.error }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
|
||||
<div
|
||||
v-for="item in summaryStats"
|
||||
:key="item.label"
|
||||
class="rounded-lg border border-border/50 bg-background/70 px-3 py-2 min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-semibold tabular-nums truncate mt-0.5"
|
||||
:class="item.tone"
|
||||
>
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.hint"
|
||||
class="text-[10px] text-muted-foreground truncate mt-0.5"
|
||||
>
|
||||
{{ item.hint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(280px,0.9fr)]">
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h5 class="text-xs font-semibold">
|
||||
在线率采样
|
||||
</h5>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
1h 桶,颜色随在线率和错误数变化
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ formatNumber(bucketItems.length) }} 点</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="bucketItems.length > 0"
|
||||
class="h-20 flex items-end gap-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="bucket in bucketItems"
|
||||
:key="bucket.bucket_start_unix_secs"
|
||||
class="flex-1 min-w-[4px] rounded-t-sm"
|
||||
:class="bucketBarClass(bucket)"
|
||||
:style="{ height: bucketBarHeight(bucket) }"
|
||||
:title="bucketTitle(bucket)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无采样数据
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<h5 class="text-xs font-semibold">
|
||||
最近事件
|
||||
</h5>
|
||||
<span class="text-[11px] text-muted-foreground tabular-nums">{{ recentEvents.length }}/8</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="recentEvents.length === 0"
|
||||
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无关键事件
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-1.5 max-h-28 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="event in recentEvents"
|
||||
:key="event.id"
|
||||
class="flex items-center gap-2 text-xs min-w-0"
|
||||
>
|
||||
<Badge
|
||||
:variant="eventTypeVariant(event.event_type)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ eventTypeLabel(event.event_type) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground truncate flex-1">{{ eventDetail(event) }}</span>
|
||||
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
实时快照
|
||||
</h5>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div
|
||||
v-for="item in snapshotItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
隧道计数器
|
||||
</h5>
|
||||
<div
|
||||
v-if="tunnelMetrics"
|
||||
class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="item in tunnelCounterItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-md bg-muted/30 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无隧道计数器
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Badge, Button } from '@/components/ui'
|
||||
import type {
|
||||
ProxyNode,
|
||||
ProxyNodeEvent,
|
||||
ProxyNodeMetricsBucket,
|
||||
ProxyNodeMetricsResponse,
|
||||
} from '@/api/proxy-nodes'
|
||||
|
||||
interface ProxyNodeDetailState {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
node: ProxyNode | null
|
||||
metrics: ProxyNodeMetricsResponse | null
|
||||
events: ProxyNodeEvent[]
|
||||
loadedAt: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: ProxyNode
|
||||
state?: ProxyNodeDetailState | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const detailNode = computed(() => props.state?.node ?? props.node)
|
||||
const metrics = computed(() => props.state?.metrics ?? null)
|
||||
const metricsSummary = computed(() => metrics.value?.summary ?? null)
|
||||
const bucketItems = computed(() => metrics.value?.items.slice(-24) ?? [])
|
||||
const recentEvents = computed(() => props.state?.events.slice(0, 8) ?? [])
|
||||
const metadata = computed(() => asRecord(detailNode.value.proxy_metadata))
|
||||
const tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
|
||||
|
||||
const loadedText = computed(() => {
|
||||
if (props.state?.loading) return '正在刷新采样数据'
|
||||
if (!props.state?.loadedAt) return '展开后自动读取 24 小时指标和最近关键事件'
|
||||
return `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
|
||||
})
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
const summary = metricsSummary.value
|
||||
const totalBytes = (summary?.ws_in_bytes_delta ?? 0) + (summary?.ws_out_bytes_delta ?? 0)
|
||||
return [
|
||||
{
|
||||
label: '在线率',
|
||||
value: formatPercent(summary?.uptime_ratio ?? null),
|
||||
hint: `${formatNumber(summary?.uptime_samples ?? 0)}/${formatNumber(summary?.samples ?? 0)} 样本`,
|
||||
tone: uptimeTone(summary?.uptime_ratio ?? null),
|
||||
},
|
||||
{
|
||||
label: '心跳 RTT',
|
||||
value: formatMs(summary?.heartbeat_rtt_ms_avg ?? null),
|
||||
hint: `峰值 ${formatMs(summary?.heartbeat_rtt_ms_max ?? null)}`,
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: '并发峰值',
|
||||
value: formatNumber(summary?.active_connections_max ?? 0),
|
||||
hint: `均值 ${formatDecimal(summary?.active_connections_avg ?? null)}`,
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: '断开',
|
||||
value: formatNumber(summary?.disconnects_delta ?? 0),
|
||||
hint: '24h delta',
|
||||
tone: (summary?.disconnects_delta ?? 0) > 0 ? 'text-yellow-600 dark:text-yellow-400' : '',
|
||||
},
|
||||
{
|
||||
label: '连接错误',
|
||||
value: formatNumber(summary?.connect_errors_delta ?? 0),
|
||||
hint: '24h delta',
|
||||
tone: (summary?.connect_errors_delta ?? 0) > 0 ? 'text-destructive' : '',
|
||||
},
|
||||
{
|
||||
label: 'WS 流量',
|
||||
value: formatBytes(totalBytes),
|
||||
hint: `${formatNumber((summary?.ws_in_frames_delta ?? 0) + (summary?.ws_out_frames_delta ?? 0))} 帧`,
|
||||
tone: '',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const snapshotItems = computed(() => {
|
||||
const node = detailNode.value
|
||||
return [
|
||||
{ label: '当前并发', value: formatNumber(node.active_connections ?? 0) },
|
||||
{ label: '心跳间隔', value: `${node.heartbeat_interval ?? '-'}s` },
|
||||
{ label: '最后心跳', value: formatTime(node.last_heartbeat_at) },
|
||||
{ label: '隧道连接', value: node.tunnel_connected ? '已连接' : '未连接' },
|
||||
{ label: '连接时间', value: formatTime(node.tunnel_connected_at) },
|
||||
{ label: '容量估算', value: node.estimated_max_concurrency == null ? '-' : formatNumber(node.estimated_max_concurrency) },
|
||||
{ label: '配置版本', value: `v${node.config_version}` },
|
||||
{ label: '注册来源', value: node.registered_by || '-' },
|
||||
{ label: '代理版本', value: stringField(metadata.value, 'version') || '-' },
|
||||
]
|
||||
})
|
||||
|
||||
const tunnelCounterItems = computed(() => [
|
||||
{ label: '建连尝试', value: formatTunnelNumber('connect_attempts') },
|
||||
{ label: '建连成功', value: formatTunnelNumber('connect_successes') },
|
||||
{ label: '建连错误', value: formatTunnelNumber('connect_errors') },
|
||||
{ label: '断开次数', value: formatTunnelNumber('disconnects') },
|
||||
{ label: '最近连上', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_connected_at_unix_secs')) },
|
||||
{ label: '最近断开', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_disconnected_at_unix_secs')) },
|
||||
{ label: '最近在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'last_connected_duration_ms')) },
|
||||
{ label: '累计在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'connected_duration_total_ms')) },
|
||||
{ label: '心跳发送', value: formatTunnelNumber('heartbeat_sent') },
|
||||
{ label: '心跳确认', value: formatTunnelNumber('heartbeat_ack') },
|
||||
{ label: '最近 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_last_ms')) },
|
||||
{ label: '平均 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_avg_ms')) },
|
||||
{ label: 'WS 入站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_in_bytes') ?? 0) },
|
||||
{ label: 'WS 出站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_out_bytes') ?? 0) },
|
||||
{ label: '入站帧', value: formatTunnelNumber('ws_in_frames') },
|
||||
{ label: '出站帧', value: formatTunnelNumber('ws_out_frames') },
|
||||
])
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown> | null, key: string): number | null {
|
||||
const value = record?.[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = record?.[key]
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function formatTunnelNumber(key: string) {
|
||||
return formatNumber(numberField(tunnelMetrics.value, key) ?? 0)
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (!Number.isFinite(value)) return '-'
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function formatDecimal(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value)) return '-'
|
||||
if (value === 0) return '0'
|
||||
if (value < 10) return value.toFixed(1)
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value)) return '-'
|
||||
return `${(value * 100).toFixed(value >= 0.995 || value === 0 ? 0 : 1)}%`
|
||||
}
|
||||
|
||||
function formatMs(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}s`
|
||||
return `${Math.round(value)}ms`
|
||||
}
|
||||
|
||||
function formatDurationMs(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
const totalSeconds = Math.floor(value / 1000)
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
if (totalMinutes < 60) return `${totalMinutes}m`
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
if (totalHours < 24) return `${totalHours}h ${totalMinutes % 60}m`
|
||||
const days = Math.floor(totalHours / 24)
|
||||
return `${days}d ${totalHours % 24}h`
|
||||
}
|
||||
|
||||
function formatUnixSecs(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
return formatTime(new Date(value * 1000).toISOString())
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = value
|
||||
let unit = 0
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${unit === 0 ? Math.round(size) : size.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null) {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const diff = (Date.now() - date.getTime()) / 1000
|
||||
if (diff >= 0 && diff < 60) return '刚刚'
|
||||
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
||||
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatLoadedAt(value: number) {
|
||||
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function bucketBarHeight(bucket: ProxyNodeMetricsBucket) {
|
||||
if (bucket.samples <= 0 || bucket.uptime_ratio == null) return '10%'
|
||||
return `${Math.max(10, Math.round(bucket.uptime_ratio * 100))}%`
|
||||
}
|
||||
|
||||
function bucketBarClass(bucket: ProxyNodeMetricsBucket) {
|
||||
if (bucket.samples <= 0) return 'bg-muted'
|
||||
if (bucket.error_events_delta > 0 || bucket.connect_errors_delta > 0) return 'bg-destructive/80'
|
||||
if ((bucket.uptime_ratio ?? 0) < 0.98 || bucket.disconnects_delta > 0) return 'bg-yellow-500/80'
|
||||
return 'bg-primary/80'
|
||||
}
|
||||
|
||||
function bucketTitle(bucket: ProxyNodeMetricsBucket) {
|
||||
return [
|
||||
formatBucketTime(bucket.bucket_start),
|
||||
`在线率 ${formatPercent(bucket.uptime_ratio)}`,
|
||||
`RTT ${formatMs(bucket.heartbeat_rtt_ms_avg)}`,
|
||||
`断开 ${formatNumber(bucket.disconnects_delta)}`,
|
||||
`错误 ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
|
||||
].join(',')
|
||||
}
|
||||
|
||||
function formatBucketTime(value: string | null) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit' })
|
||||
}
|
||||
|
||||
function uptimeTone(value: number | null) {
|
||||
if (value == null) return ''
|
||||
if (value < 0.95) return 'text-destructive'
|
||||
if (value < 0.99) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-primary'
|
||||
}
|
||||
|
||||
function eventTypeLabel(type: string) {
|
||||
switch (type) {
|
||||
case 'connected': return '连接'
|
||||
case 'disconnected': return '断开'
|
||||
case 'error': return '错误'
|
||||
case 'tunnel_err': return '隧道错误'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
function eventTypeVariant(type: string) {
|
||||
switch (type) {
|
||||
case 'connected': return 'success' as const
|
||||
case 'disconnected':
|
||||
case 'error':
|
||||
case 'tunnel_err':
|
||||
return 'destructive' as const
|
||||
default: return 'secondary' as const
|
||||
}
|
||||
}
|
||||
|
||||
function eventDetail(event: ProxyNodeEvent) {
|
||||
const metadata = asRecord(event.event_metadata)
|
||||
return event.detail || stringField(metadata, 'message') || stringField(metadata, 'category') || '-'
|
||||
}
|
||||
</script>
|
||||
@@ -192,6 +192,72 @@
|
||||
独立控制候选记录大表清理节奏,不再跟随 Key 删除联动
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="proxy-node-metrics-1m-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
代理 1m 指标保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-node-metrics-1m-retention-days"
|
||||
:model-value="proxyNodeMetrics1mRetentionDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:proxyNodeMetrics1mRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
用于代理节点稳定性分钟级图表,后端最少保留 1 天
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="proxy-node-metrics-1h-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
代理 1h 指标保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-node-metrics-1h-retention-days"
|
||||
:model-value="proxyNodeMetrics1hRetentionDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1095"
|
||||
placeholder="180"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:proxyNodeMetrics1hRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
小时级聚合用于长期趋势,不能短于 1m 指标保留天数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="proxy-node-metrics-cleanup-batch-size"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
代理指标清理批次
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-node-metrics-cleanup-batch-size"
|
||||
:model-value="proxyNodeMetricsCleanupBatchSize"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50000"
|
||||
placeholder="5000"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:proxyNodeMetricsCleanupBatchSize', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
独立限制 proxy_node_metrics 表的单批删除数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理策略说明 -->
|
||||
@@ -206,6 +272,7 @@
|
||||
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
|
||||
<p>5. <strong>候选记录</strong>: 独立按保留天数清理 request_candidates 审计记录,不再跟随 Key 删除联动</p>
|
||||
<p>6. <strong>审计日志</strong>: 独立清理,记录用户登录、操作等安全事件</p>
|
||||
<p>7. <strong>代理指标</strong>: 仅保留 1m/1h 聚合桶,清理任务按批次删除过期桶</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +389,9 @@ defineProps<{
|
||||
auditLogRetentionDays: number
|
||||
requestCandidatesRetentionDays: number
|
||||
requestCandidatesCleanupBatchSize: number
|
||||
proxyNodeMetrics1mRetentionDays: number
|
||||
proxyNodeMetrics1hRetentionDays: number
|
||||
proxyNodeMetricsCleanupBatchSize: number
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
@@ -337,6 +407,9 @@ defineEmits<{
|
||||
'update:auditLogRetentionDays': [value: number]
|
||||
'update:requestCandidatesRetentionDays': [value: number]
|
||||
'update:requestCandidatesCleanupBatchSize': [value: number]
|
||||
'update:proxyNodeMetrics1mRetentionDays': [value: number]
|
||||
'update:proxyNodeMetrics1hRetentionDays': [value: number]
|
||||
'update:proxyNodeMetricsCleanupBatchSize': [value: number]
|
||||
}>()
|
||||
|
||||
const cleanupRuns = ref<CleanupRunRecord[]>([])
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface SystemConfig {
|
||||
audit_log_retention_days: number
|
||||
request_candidates_retention_days: number
|
||||
request_candidates_cleanup_batch_size: number
|
||||
proxy_node_metrics_1m_retention_days: number
|
||||
proxy_node_metrics_1h_retention_days: number
|
||||
proxy_node_metrics_cleanup_batch_size: number
|
||||
// 定时任务
|
||||
enable_provider_checkin: boolean
|
||||
provider_checkin_time: string
|
||||
@@ -70,6 +73,9 @@ const CONFIG_KEYS = [
|
||||
'audit_log_retention_days',
|
||||
'request_candidates_retention_days',
|
||||
'request_candidates_cleanup_batch_size',
|
||||
'proxy_node_metrics_1m_retention_days',
|
||||
'proxy_node_metrics_1h_retention_days',
|
||||
'proxy_node_metrics_cleanup_batch_size',
|
||||
// 定时任务
|
||||
'enable_provider_checkin',
|
||||
'provider_checkin_time',
|
||||
@@ -107,6 +113,9 @@ function createDefaultConfig(): SystemConfig {
|
||||
audit_log_retention_days: 30,
|
||||
request_candidates_retention_days: 30,
|
||||
request_candidates_cleanup_batch_size: 5000,
|
||||
proxy_node_metrics_1m_retention_days: 30,
|
||||
proxy_node_metrics_1h_retention_days: 180,
|
||||
proxy_node_metrics_cleanup_batch_size: 5000,
|
||||
// 定时任务
|
||||
enable_provider_checkin: true,
|
||||
provider_checkin_time: '01:05',
|
||||
@@ -181,7 +190,13 @@ export function useSystemConfig() {
|
||||
systemConfig.value.request_candidates_retention_days !==
|
||||
originalConfig.value.request_candidates_retention_days ||
|
||||
systemConfig.value.request_candidates_cleanup_batch_size !==
|
||||
originalConfig.value.request_candidates_cleanup_batch_size
|
||||
originalConfig.value.request_candidates_cleanup_batch_size ||
|
||||
systemConfig.value.proxy_node_metrics_1m_retention_days !==
|
||||
originalConfig.value.proxy_node_metrics_1m_retention_days ||
|
||||
systemConfig.value.proxy_node_metrics_1h_retention_days !==
|
||||
originalConfig.value.proxy_node_metrics_1h_retention_days ||
|
||||
systemConfig.value.proxy_node_metrics_cleanup_batch_size !==
|
||||
originalConfig.value.proxy_node_metrics_cleanup_batch_size
|
||||
)
|
||||
})
|
||||
|
||||
@@ -441,6 +456,21 @@ export function useSystemConfig() {
|
||||
value: systemConfig.value.request_candidates_cleanup_batch_size,
|
||||
description: '请求候选记录每批次清理条数',
|
||||
},
|
||||
{
|
||||
key: 'proxy_node_metrics_1m_retention_days',
|
||||
value: systemConfig.value.proxy_node_metrics_1m_retention_days,
|
||||
description: '代理节点 1m 指标保留天数',
|
||||
},
|
||||
{
|
||||
key: 'proxy_node_metrics_1h_retention_days',
|
||||
value: systemConfig.value.proxy_node_metrics_1h_retention_days,
|
||||
description: '代理节点 1h 指标保留天数',
|
||||
},
|
||||
{
|
||||
key: 'proxy_node_metrics_cleanup_batch_size',
|
||||
value: systemConfig.value.proxy_node_metrics_cleanup_batch_size,
|
||||
description: '代理节点指标每批次清理条数',
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
@@ -462,6 +492,12 @@ export function useSystemConfig() {
|
||||
systemConfig.value.request_candidates_retention_days
|
||||
originalConfig.value.request_candidates_cleanup_batch_size =
|
||||
systemConfig.value.request_candidates_cleanup_batch_size
|
||||
originalConfig.value.proxy_node_metrics_1m_retention_days =
|
||||
systemConfig.value.proxy_node_metrics_1m_retention_days
|
||||
originalConfig.value.proxy_node_metrics_1h_retention_days =
|
||||
systemConfig.value.proxy_node_metrics_1h_retention_days
|
||||
originalConfig.value.proxy_node_metrics_cleanup_batch_size =
|
||||
systemConfig.value.proxy_node_metrics_cleanup_batch_size
|
||||
}
|
||||
success('请求记录清理配置已保存')
|
||||
} catch (err) {
|
||||
|
||||
@@ -191,6 +191,15 @@
|
||||
<!-- 操作按钮 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="一键安装并配置 CLI"
|
||||
@click="openInstallDialog(apiKey)"
|
||||
>
|
||||
<Terminal class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -273,6 +282,15 @@
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="一键安装并配置 CLI"
|
||||
@click="openInstallDialog(apiKey)"
|
||||
>
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -503,13 +521,110 @@
|
||||
<template #footer>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
@click="showKeyDialog = false"
|
||||
@click="closeCreatedKeyDialog"
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 一键安装并配置 CLI 对话框 -->
|
||||
<Dialog
|
||||
v-model="showInstallDialog"
|
||||
size="lg"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Terminal class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
一键安装并配置 CLI
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
当前密钥:{{ selectedInstallApiKey?.name || '未选择' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
选择要配置的 CLI 和目标系统,Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-semibold">目标 CLI</Label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
v-for="option in installCliOptions"
|
||||
:key="option.value"
|
||||
:variant="installCli === option.value ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="selectInstallCli(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-semibold">目标系统</Label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
v-for="option in installSystemOptions"
|
||||
:key="option.value"
|
||||
:variant="installSystem === option.value ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="selectInstallSystem(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="installLoading || !selectedInstallApiKey"
|
||||
@click="refreshInstallCommand"
|
||||
>
|
||||
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ installCommandHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="showInstallDialog = false"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||
:disabled="!installCommand || installLoading"
|
||||
@click="copyTextToClipboard(installCommand)"
|
||||
>
|
||||
复制命令
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showDeleteDialog"
|
||||
@@ -525,8 +640,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { meApi, type ApiKey } from '@/api/me'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -543,17 +658,28 @@ import {
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen } from 'lucide-vue-next'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatRateLimitSimple } from '@/utils/format'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
||||
{ value: 'claude_code', label: 'Claude Code' },
|
||||
{ value: 'codex_cli', label: 'Codex CLI' },
|
||||
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||
]
|
||||
|
||||
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
|
||||
{ value: 'macos', label: 'macOS' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'windows', label: 'Windows' }
|
||||
]
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
@@ -571,6 +697,7 @@ const paginatedApiKeys = computed(() => {
|
||||
const showCreateDialog = ref(false)
|
||||
const showKeyDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showInstallDialog = ref(false)
|
||||
|
||||
const newKeyName = ref('')
|
||||
const newKeyRateLimit = ref<number | undefined>(undefined)
|
||||
@@ -578,11 +705,38 @@ const newKeyConcurrentLimit = ref<number | undefined>(undefined)
|
||||
const newKeyValue = ref('')
|
||||
const keyToDelete = ref<ApiKey | null>(null)
|
||||
const editingApiKey = ref<ApiKey | null>(null)
|
||||
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
||||
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
||||
const installCli = ref<InstallTargetCli>('claude_code')
|
||||
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
|
||||
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||
const installLoading = ref(false)
|
||||
|
||||
const installCommand = computed(() => {
|
||||
if (!installSession.value) return ''
|
||||
return installSystem.value === 'windows'
|
||||
? installSession.value.powershell_command
|
||||
: installSession.value.unix_command
|
||||
})
|
||||
|
||||
const installCommandHint = computed(() => {
|
||||
if (installSystem.value === 'windows') {
|
||||
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||
}
|
||||
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
installSystem.value = detectCurrentSystem()
|
||||
loadApiKeys()
|
||||
})
|
||||
|
||||
watch(showKeyDialog, (isOpen) => {
|
||||
if (!isOpen && pendingFirstInstallApiKey.value) {
|
||||
closeCreatedKeyDialog()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -618,6 +772,57 @@ function openCreateApiKeyDialog() {
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
||||
const platform = window.navigator.platform.toLowerCase()
|
||||
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||
if (platform.includes('mac')) return 'macos'
|
||||
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
|
||||
return 'linux'
|
||||
}
|
||||
|
||||
async function openInstallDialog(apiKey: ApiKey) {
|
||||
selectedInstallApiKey.value = apiKey
|
||||
installSession.value = null
|
||||
showInstallDialog.value = true
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function selectInstallCli(value: InstallTargetCli) {
|
||||
installCli.value = value
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
|
||||
installSystem.value = value
|
||||
await refreshInstallCommand()
|
||||
}
|
||||
|
||||
async function refreshInstallCommand() {
|
||||
if (!selectedInstallApiKey.value) return
|
||||
installLoading.value = true
|
||||
installSession.value = null
|
||||
try {
|
||||
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||
target_cli: installCli.value,
|
||||
target_system: installSystem.value,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('生成 CLI 安装命令失败:', error)
|
||||
showError(parseApiError(error, '生成 CLI 安装命令失败'))
|
||||
} finally {
|
||||
installLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreatedKeyDialog() {
|
||||
showKeyDialog.value = false
|
||||
const pending = pendingFirstInstallApiKey.value
|
||||
pendingFirstInstallApiKey.value = null
|
||||
if (pending) {
|
||||
void openInstallDialog(pending)
|
||||
}
|
||||
}
|
||||
|
||||
function closeApiKeyDialog() {
|
||||
showCreateDialog.value = false
|
||||
editingApiKey.value = null
|
||||
@@ -634,6 +839,7 @@ async function saveApiKey() {
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const isCreatingFirstApiKey = !editingApiKey.value && apiKeys.value.length === 0
|
||||
if (editingApiKey.value) {
|
||||
await meApi.updateApiKey(editingApiKey.value.id, {
|
||||
name: newKeyName.value,
|
||||
@@ -648,6 +854,9 @@ async function saveApiKey() {
|
||||
concurrent_limit: newKeyConcurrentLimit.value,
|
||||
})
|
||||
newKeyValue.value = newKey.key || ''
|
||||
if (isCreatingFirstApiKey) {
|
||||
pendingFirstInstallApiKey.value = newKey
|
||||
}
|
||||
showKeyDialog.value = true
|
||||
success('API 密钥创建成功')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user