mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy): record tunnel stability metrics
This commit is contained in:
@@ -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>
|
||||
@@ -1181,59 +1150,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 +1182,52 @@ 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 fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
|
||||
if (fromRequestMetadata) return fromRequestMetadata
|
||||
|
||||
const summaryParts: string[] = []
|
||||
if (rankingMode) summaryParts.push(formatRankingModeLabel(rankingMode))
|
||||
if (promotedBy) summaryParts.push(formatRankingReasonLabel(promotedBy))
|
||||
if (demotedBy) summaryParts.push(formatRankingReasonLabel(demotedBy))
|
||||
|
||||
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 +1241,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 ''
|
||||
@@ -1870,8 +1663,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 +1691,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
|
||||
}
|
||||
|
||||
@@ -99,12 +99,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 +273,52 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user