Fix Codex image progress heartbeat merge regressions

This commit is contained in:
fawney19
2026-05-10 02:10:23 +08:00
156 changed files with 11741 additions and 943 deletions

View File

@@ -236,6 +236,28 @@ export interface EmailTemplateResetResponse {
}
}
export interface CleanupRunRecord {
id: string
kind: string
trigger: string
status: 'processing' | 'completed' | 'failed'
message: string
started_at_unix_secs: number
completed_at_unix_secs: number | null
duration_ms: number | null
summary: Record<string, unknown>
error: string | null
}
export interface CleanupRunListResponse {
items: CleanupRunRecord[]
}
export interface CleanupTaskResponse {
message: string
task: CleanupRunRecord
}
// 检查更新响应
export interface CheckUpdateResponse {
current_version: string
@@ -1051,12 +1073,29 @@ export const adminApi = {
},
// 数据清空
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
purgeUsage: () => purge<{ message: string; deleted: Record<string, number> }>('usage'),
purgeAuditLogs: () => purge<{ message: string; deleted: Record<string, number> }>('audit-logs'),
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
purgeStats: () => purge<{ message: string }>('stats'),
purgeConfig: () => purge<CleanupTaskResponse>('config'),
purgeUsers: () => purge<CleanupTaskResponse>('users'),
async purgeUsage(): Promise<CleanupTaskResponse> {
const response = await apiClient.post<CleanupTaskResponse>('/api/admin/system/purge/usage')
return response.data
},
async purgeAuditLogs(): Promise<CleanupTaskResponse> {
const response = await apiClient.post<CleanupTaskResponse>('/api/admin/system/purge/audit-logs')
return response.data
},
purgeRequestBodies: () => purge<CleanupTaskResponse>('request-bodies'),
async purgeRequestBodiesAsync(): Promise<CleanupTaskResponse> {
const response = await apiClient.post<CleanupTaskResponse>('/api/admin/system/purge/request-bodies/task')
return response.data
},
async purgeStats(): Promise<CleanupTaskResponse> {
const response = await apiClient.post<CleanupTaskResponse>('/api/admin/system/purge/stats')
return response.data
},
async getCleanupRuns(): Promise<CleanupRunListResponse> {
const response = await apiClient.get<CleanupRunListResponse>('/api/admin/system/cleanup/runs')
return response.data
},
async getTimeSeries(params?: {
start_date?: string

View File

@@ -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
},
}

View File

@@ -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

View File

@@ -22,6 +22,15 @@ export interface ImageProgress {
downstream_heartbeat_interval_ms?: number | null
}
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
@@ -60,7 +69,10 @@ export interface CandidateRecord {
concurrent_requests?: number
ranking?: CandidateRankingMetadata | null
image_progress?: ImageProgress | null
extra_data?: Record<string, unknown>
extra_data?: Record<string, unknown> & {
upstream_response?: CandidateResponseBoundary
image_progress?: ImageProgress | null
}
created_at: string
started_at?: string
finished_at?: string
@@ -68,6 +80,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

View File

@@ -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()

View File

@@ -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(() => {

View File

@@ -492,38 +492,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">
@@ -531,7 +535,7 @@
</summary>
<JsonContentPanel
class="extra-json-panel"
:data="currentAttempt.extra_data"
:data="currentAttemptExtraDataDisplay"
:is-dark="isDark"
empty-message="无额外信息"
/>
@@ -569,6 +573,7 @@ import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImagePro
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,
@@ -620,17 +625,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 的判断 */
@@ -710,7 +704,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)
@@ -1133,9 +1127,11 @@ 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 normalizeImageProgress = (value: unknown): ImageProgress | null => {
@@ -1244,60 +1240,35 @@ watch(
{ immediate: 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
@@ -1390,6 +1361,9 @@ const currentAttemptRequestPathDisplay = computed(() => {
const fromAttempt = resolveRequestPathFromObject(attempt?.extra_data)
if (fromAttempt) return fromAttempt
const fromTrace = resolveRequestPathFromObject(trace.value)
if (fromTrace) return fromTrace
const fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
if (fromRequestMetadata) return fromRequestMetadata
@@ -1456,45 +1430,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 []
@@ -2830,50 +2823,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;
}

View File

@@ -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'
@@ -1040,10 +992,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
@@ -1074,27 +1023,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),
)

View File

@@ -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))
},
}),
}
@@ -359,4 +365,81 @@ describe('HorizontalRequestTimeline', () => {
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('该错误被标记为敏感上游错误')
})
})

View File

@@ -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)
)
})

View File

@@ -275,10 +275,104 @@
<p>7. <strong>代理指标</strong>: 仅保留 1m/1h 聚合桶清理任务按批次删除过期桶</p>
</div>
</div>
<div class="mt-4 border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
<div>
<h4 class="text-sm font-medium">
最近清理记录
</h4>
<p class="text-xs text-muted-foreground">
自动清理手动系统清理和请求体后台任务的执行结果
</p>
</div>
<Button
variant="outline"
size="sm"
:disabled="cleanupRunsLoading"
@click="loadCleanupRuns"
>
<RefreshCw
class="w-3.5 h-3.5 mr-1.5"
:class="{ 'animate-spin': cleanupRunsLoading }"
/>
刷新
</Button>
</div>
<div
v-if="cleanupRuns.length === 0 && !cleanupRunsLoading"
class="px-4 py-6 text-sm text-muted-foreground"
>
暂无清理记录
</div>
<div
v-else
class="overflow-x-auto"
>
<table class="w-full text-sm">
<thead class="bg-muted/30 text-xs text-muted-foreground">
<tr>
<th class="px-4 py-2 text-left font-medium">
时间
</th>
<th class="px-4 py-2 text-left font-medium">
类型
</th>
<th class="px-4 py-2 text-left font-medium">
来源
</th>
<th class="px-4 py-2 text-left font-medium">
状态
</th>
<th class="px-4 py-2 text-left font-medium">
结果
</th>
<th class="px-4 py-2 text-right font-medium">
耗时
</th>
</tr>
</thead>
<tbody>
<tr
v-for="run in cleanupRuns"
:key="run.id"
class="border-t border-border"
>
<td class="px-4 py-2 whitespace-nowrap">
{{ formatRunTime(run.started_at_unix_secs) }}
</td>
<td class="px-4 py-2 whitespace-nowrap">
{{ cleanupKindLabel(run.kind) }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-muted-foreground">
{{ run.trigger === 'manual' ? '手动' : '自动' }}
</td>
<td class="px-4 py-2 whitespace-nowrap">
<span :class="cleanupStatusClass(run.status)">
{{ cleanupStatusLabel(run.status) }}
</span>
</td>
<td class="px-4 py-2 min-w-[18rem]">
<div>{{ run.error || run.message }}</div>
<div class="text-xs text-muted-foreground">
{{ cleanupSummaryText(run.summary) }}
</div>
</td>
<td class="px-4 py-2 text-right whitespace-nowrap text-muted-foreground">
{{ formatDuration(run.duration_ms) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import { adminApi, type CleanupRunRecord } from '@/api/admin'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
@@ -317,4 +411,95 @@ defineEmits<{
'update:proxyNodeMetrics1hRetentionDays': [value: number]
'update:proxyNodeMetricsCleanupBatchSize': [value: number]
}>()
const cleanupRuns = ref<CleanupRunRecord[]>([])
const cleanupRunsLoading = ref(false)
let cleanupRunsTimer: ReturnType<typeof window.setInterval> | null = null
async function loadCleanupRuns() {
cleanupRunsLoading.value = true
try {
const response = await adminApi.getCleanupRuns()
cleanupRuns.value = response.items.slice(0, 10)
} finally {
cleanupRunsLoading.value = false
}
}
function cleanupKindLabel(kind: string): string {
const labels: Record<string, string> = {
usage_cleanup: '请求记录',
audit_cleanup: '审计日志',
request_candidate_cleanup: '候选记录',
request_bodies: '请求体',
config_purge: '配置清空',
users_purge: '用户清空',
usage_purge: '使用记录清空',
audit_logs_purge: '审计日志清空',
stats_purge: '统计聚合清空',
system_cleanup: '系统清理',
}
return labels[kind] || kind
}
function cleanupStatusLabel(status: string): string {
if (status === 'processing') return '执行中'
if (status === 'failed') return '失败'
return '完成'
}
function cleanupStatusClass(status: string): string {
if (status === 'processing') return 'text-amber-500'
if (status === 'failed') return 'text-destructive'
return 'text-emerald-500'
}
function formatRunTime(value: number): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString()
}
function formatDuration(value: number | null): string {
if (value === null || value === undefined) return '-'
if (value < 1000) return `${value}ms`
return `${(value / 1000).toFixed(1)}s`
}
function cleanupSummaryText(summary: Record<string, unknown>): string {
const total = typeof summary.total === 'number' ? summary.total : null
if (total !== null) return `影响 ${total}`
const entries = Object.entries(summary)
.filter(([, value]) => typeof value === 'number' && value > 0)
.map(([key, value]) => `${summaryLabel(key)} ${value}`)
return entries.length > 0 ? entries.join(' / ') : '无数据变更'
}
function summaryLabel(key: string): string {
const labels: Record<string, string> = {
body_externalized: '压缩',
legacy_body_refs_migrated: '迁移',
body_cleaned: '清体',
header_cleaned: '清头',
keys_cleaned: 'Key',
records_deleted: '删记录',
audit_logs_deleted: '删日志',
request_candidates_deleted: '删候选',
}
return labels[key] || key
}
onMounted(() => {
void loadCleanupRuns()
cleanupRunsTimer = window.setInterval(() => {
void loadCleanupRuns()
}, 15_000)
})
onBeforeUnmount(() => {
if (cleanupRunsTimer) {
window.clearInterval(cleanupRunsTimer)
cleanupRunsTimer = null
}
})
</script>

View File

@@ -62,55 +62,55 @@ const purgeItems: PurgeItem[] = [
{
key: 'config',
title: '清空配置',
description: '删除所有提供商、端点、API Key 和模型配置',
description: '后台删除所有提供商、端点、API Key 和模型配置',
buttonText: '清空配置',
icon: markRaw(Settings),
confirmMessage: '确定要清空所有提供商配置吗这将删除所有提供商、端点、API Key 和模型配置,操作不可逆。',
confirmMessage: '确定要后台清空所有提供商配置吗这将删除所有提供商、端点、API Key 和模型配置,操作不可逆。',
action: () => adminApi.purgeConfig(),
},
{
key: 'users',
title: '清空用户',
description: '删除所有非管理员用户及其 API Keys',
description: '后台删除所有非管理员用户及其 API Keys',
buttonText: '清空用户',
icon: markRaw(Users),
confirmMessage: '确定要清空所有非管理员用户吗?管理员账户将被保留,操作不可逆。',
confirmMessage: '确定要后台清空所有非管理员用户吗?管理员账户将被保留,操作不可逆。',
action: () => adminApi.purgeUsers(),
},
{
key: 'usage',
title: '清空使用记录',
description: '删除全部使用记录和请求候选记录',
description: '后台清空全部使用记录和请求候选记录',
buttonText: '清空记录',
icon: markRaw(BarChart3),
confirmMessage: '确定要清空全部使用记录吗?所有请求统计数据将被永久删除,操作不可逆。',
confirmMessage: '确定要后台清空全部使用记录吗?所有请求统计数据将被永久删除,操作不可逆。',
action: () => adminApi.purgeUsage(),
},
{
key: 'audit-logs',
title: '清空审计日志',
description: '删除全部审计日志记录',
description: '后台删除全部审计日志记录',
buttonText: '清空日志',
icon: markRaw(Shield),
confirmMessage: '确定要清空全部审计日志吗?所有安全事件记录将被永久删除,操作不可逆。',
confirmMessage: '确定要后台清空全部审计日志吗?所有安全事件记录将被永久删除,操作不可逆。',
action: () => adminApi.purgeAuditLogs(),
},
{
key: 'request-bodies',
title: '清空请求体',
description: '清空所有请求/响应体数据,保留统计信息',
description: '后台分批清空所有请求/响应体数据,保留统计信息',
buttonText: '清空请求体',
icon: markRaw(FileText),
confirmMessage: '确定要清空全部请求体吗?请求/响应内容将被清除,但 token 和成本等统计信息会保留,操作不可逆。',
action: () => adminApi.purgeRequestBodies(),
confirmMessage: '确定要后台清空全部请求体吗?请求/响应内容将被分批清除,但 token 和成本等统计信息会保留,操作不可逆。',
action: () => adminApi.purgeRequestBodiesAsync(),
},
{
key: 'stats',
title: '清空统计聚合',
description: '删除统计聚合数据,保留原始使用记录;统计可从原始使用记录重新构建',
description: '后台删除统计聚合数据并重建,保留原始使用记录',
buttonText: '清空统计聚合',
icon: markRaw(PieChart),
confirmMessage: '确定要清空全部统计聚合数据吗?原始使用记录会保留,仪表盘和累计统计从原始记录重新构建。',
confirmMessage: '确定要后台清空全部统计聚合数据吗?原始使用记录会保留,仪表盘和累计统计从原始记录重新构建。',
action: () => adminApi.purgeStats(),
},
]

View File

@@ -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 密钥创建成功')
}