mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: add adaptive pool metrics and self-check
This commit is contained in:
@@ -28,6 +28,11 @@ export interface PoolStatusResponse {
|
||||
pool_enabled: boolean
|
||||
total_keys: number
|
||||
total_sticky_sessions: number
|
||||
provider_hot_count: number
|
||||
provider_desired_hot: number
|
||||
provider_in_flight: number
|
||||
provider_ema_in_flight: number
|
||||
provider_burst_pending: boolean
|
||||
keys: PoolKeyStatus[]
|
||||
}
|
||||
|
||||
@@ -77,6 +82,11 @@ export interface PoolOverviewItem {
|
||||
active_keys: number
|
||||
cooldown_count: number
|
||||
pool_enabled: boolean
|
||||
provider_hot_count?: number
|
||||
provider_desired_hot?: number
|
||||
provider_in_flight?: number
|
||||
provider_ema_in_flight?: number
|
||||
provider_burst_pending?: boolean
|
||||
}
|
||||
|
||||
export interface PoolOverviewResponse {
|
||||
|
||||
@@ -584,6 +584,15 @@ export interface PoolAdvancedConfig {
|
||||
score_rules?: PoolScoreRules | null
|
||||
probing_enabled?: boolean
|
||||
probing_interval_minutes?: number | null
|
||||
// deprecated: retained only for backward-compatible reads
|
||||
probing_target_percent?: number | null
|
||||
// deprecated: retained only for backward-compatible reads
|
||||
probing_target_count?: number | null
|
||||
account_self_check_enabled?: boolean
|
||||
account_self_check_interval_minutes?: number | null
|
||||
account_self_check_concurrency?: number | null
|
||||
account_self_check_method?: 'quota_refresh' | 'custom_request' | string
|
||||
account_self_check_request?: Record<string, unknown> | null
|
||||
auto_remove_banned_keys?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.account_self_check_enabled"
|
||||
class="space-y-3 rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
自检间隔
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.account_self_check_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="60"
|
||||
@update:model-value="(v) => form.account_self_check_interval_minutes = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
自检并发
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.account_self_check_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
placeholder="4"
|
||||
@update:model-value="(v) => form.account_self_check_concurrency = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
自检方式
|
||||
</Label>
|
||||
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
|
||||
<button
|
||||
v-for="opt in accountSelfCheckMethodOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="rounded px-2.5 py-1 text-xs font-medium transition-all"
|
||||
:class="[
|
||||
form.account_self_check_method === opt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
|
||||
]"
|
||||
@click="form.account_self_check_method = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.account_self_check_method === 'custom_request'"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>请求配置</Label>
|
||||
<Textarea
|
||||
v-model="form.account_self_check_request_text"
|
||||
class="min-h-[160px] font-mono text-xs leading-5"
|
||||
spellcheck="false"
|
||||
placeholder='{"path":"/v1/models","success_status_codes":[200],"blocked_status_codes":[401,403]}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-3 sm:grid-cols-2"
|
||||
:class="cooldownFieldLayout.desktopColumnsClass"
|
||||
@@ -301,7 +370,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
调整主动探测、健康、额度、延迟和使用成本进入号池候选排序时的权重。
|
||||
调整探测结果、健康、额度、延迟和使用成本进入号池候选排序时的权重。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -606,7 +675,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
|
||||
import { Dialog, Button, Input, Label, Switch, Textarea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
@@ -647,6 +716,12 @@ const healthToggleCards = buildPoolHealthToggleCards()
|
||||
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
|
||||
const costFieldLayout = buildPoolCostFieldLayout()
|
||||
const secondarySectionLayout = buildPoolSecondarySectionLayout()
|
||||
const accountSelfCheckMethodOptions = [
|
||||
{ value: 'quota_refresh', label: '刷新额度' },
|
||||
{ value: 'custom_request', label: '自定义请求' },
|
||||
] as const
|
||||
|
||||
type AccountSelfCheckMethod = typeof accountSelfCheckMethodOptions[number]['value']
|
||||
|
||||
const form = ref({
|
||||
global_priority: null as number | null | undefined,
|
||||
@@ -674,6 +749,11 @@ const form = ref({
|
||||
probe_failure_cooldown_threshold: null as number | null | undefined,
|
||||
probing_enabled: false,
|
||||
probing_interval_minutes: null as number | null | undefined,
|
||||
account_self_check_enabled: false,
|
||||
account_self_check_interval_minutes: null as number | null | undefined,
|
||||
account_self_check_concurrency: null as number | null | undefined,
|
||||
account_self_check_method: 'quota_refresh' as AccountSelfCheckMethod,
|
||||
account_self_check_request_text: '',
|
||||
auto_remove_banned_keys: false,
|
||||
skip_exhausted_accounts: false,
|
||||
})
|
||||
@@ -704,12 +784,45 @@ function parseNum(v: string | number): number | undefined {
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
function normalizeAccountSelfCheckMethod(value: unknown): AccountSelfCheckMethod {
|
||||
return value === 'custom_request' ? 'custom_request' : 'quota_refresh'
|
||||
}
|
||||
|
||||
function formatJsonForTextarea(value: unknown): string {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return ''
|
||||
}
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function parseJsonObjectText(text: string): Record<string, unknown> | undefined | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
showError('账号自检请求 JSON 格式不正确')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
showError('账号自检请求必须是 JSON 对象')
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
|
||||
switch (key) {
|
||||
case 'health_policy_enabled':
|
||||
return form.value.health_policy_enabled
|
||||
case 'probing_enabled':
|
||||
return form.value.probing_enabled
|
||||
case 'account_self_check_enabled':
|
||||
return form.value.account_self_check_enabled
|
||||
case 'auto_remove_banned_keys':
|
||||
return form.value.auto_remove_banned_keys
|
||||
case 'skip_exhausted_accounts':
|
||||
@@ -725,6 +838,9 @@ function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void
|
||||
case 'probing_enabled':
|
||||
form.value.probing_enabled = value
|
||||
return
|
||||
case 'account_self_check_enabled':
|
||||
form.value.account_self_check_enabled = value
|
||||
return
|
||||
case 'auto_remove_banned_keys':
|
||||
form.value.auto_remove_banned_keys = value
|
||||
return
|
||||
@@ -765,6 +881,11 @@ watch(() => props.modelValue, (open) => {
|
||||
probe_failure_cooldown_threshold: scoreRules?.probe_failure_cooldown_threshold ?? null,
|
||||
probing_enabled: cfg?.probing_enabled ?? false,
|
||||
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
|
||||
account_self_check_enabled: cfg?.account_self_check_enabled ?? false,
|
||||
account_self_check_interval_minutes: cfg?.account_self_check_interval_minutes ?? null,
|
||||
account_self_check_concurrency: cfg?.account_self_check_concurrency ?? null,
|
||||
account_self_check_method: normalizeAccountSelfCheckMethod(cfg?.account_self_check_method),
|
||||
account_self_check_request_text: formatJsonForTextarea(cfg?.account_self_check_request),
|
||||
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
|
||||
skip_exhausted_accounts: cfg?.skip_exhausted_accounts ?? false,
|
||||
}
|
||||
@@ -784,6 +905,12 @@ watch(() => props.modelValue, (open) => {
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const accountSelfCheckRequest = form.value.account_self_check_enabled
|
||||
&& form.value.account_self_check_method === 'custom_request'
|
||||
? parseJsonObjectText(form.value.account_self_check_request_text)
|
||||
: undefined
|
||||
if (accountSelfCheckRequest === null) return
|
||||
|
||||
const scoreRules = {
|
||||
...(props.currentConfig?.score_rules ?? {}),
|
||||
weights: {
|
||||
@@ -801,9 +928,20 @@ async function handleSave() {
|
||||
request_failure_penalty: form.value.request_failure_penalty ?? undefined,
|
||||
probe_failure_cooldown_threshold: form.value.probe_failure_cooldown_threshold ?? undefined,
|
||||
}
|
||||
const existingPoolAdvanced: Record<string, unknown> = { ...(props.currentConfig ?? {}) }
|
||||
for (const key of [
|
||||
'probing_target_percent',
|
||||
'probing_target_count',
|
||||
'probing_active_target_percent',
|
||||
'probing_active_target_count',
|
||||
'active_probe_target_percent',
|
||||
'active_probe_target_count',
|
||||
]) {
|
||||
delete existingPoolAdvanced[key]
|
||||
}
|
||||
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
|
||||
const poolAdvanced: Record<string, unknown> = {
|
||||
...(props.currentConfig ?? {}),
|
||||
...existingPoolAdvanced,
|
||||
global_priority: form.value.global_priority ?? undefined,
|
||||
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
|
||||
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
|
||||
@@ -821,6 +959,20 @@ async function handleSave() {
|
||||
probing_interval_minutes: form.value.probing_enabled
|
||||
? (form.value.probing_interval_minutes ?? undefined)
|
||||
: undefined,
|
||||
account_self_check_enabled: form.value.account_self_check_enabled,
|
||||
account_self_check_interval_minutes: form.value.account_self_check_enabled
|
||||
? (form.value.account_self_check_interval_minutes ?? undefined)
|
||||
: undefined,
|
||||
account_self_check_concurrency: form.value.account_self_check_enabled
|
||||
? (form.value.account_self_check_concurrency ?? undefined)
|
||||
: undefined,
|
||||
account_self_check_method: form.value.account_self_check_enabled
|
||||
? form.value.account_self_check_method
|
||||
: undefined,
|
||||
account_self_check_request: form.value.account_self_check_enabled
|
||||
&& form.value.account_self_check_method === 'custom_request'
|
||||
? accountSelfCheckRequest
|
||||
: undefined,
|
||||
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
|
||||
skip_exhausted_accounts: form.value.skip_exhausted_accounts,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
:no-padding="true"
|
||||
size="3xl"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-4 py-4 sm:px-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
自适应热池指标
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ providerName || '当前 Provider' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
title="关闭"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-h-[calc(100dvh-13rem)] space-y-4 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:pr-2">
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
class="rounded-lg border border-border/60 bg-card/70 px-3 py-3"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ item.hint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="rounded-lg border border-border/60 bg-card/70 p-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
最近采样
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ sampleWindowText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span
|
||||
v-for="item in legendItems"
|
||||
:key="item.label"
|
||||
class="inline-flex items-center gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="item.dotClass"
|
||||
/>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="samples.length === 0"
|
||||
class="mt-4 flex h-56 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground"
|
||||
>
|
||||
暂无采样
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 h-56 rounded-lg border border-border/50 bg-background/60 p-3"
|
||||
>
|
||||
<svg
|
||||
class="h-full w-full overflow-visible"
|
||||
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="自适应热池趋势"
|
||||
>
|
||||
<line
|
||||
v-for="tick in yTicks"
|
||||
:key="tick.y"
|
||||
x1="0"
|
||||
:x2="chartWidth"
|
||||
:y1="tick.y"
|
||||
:y2="tick.y"
|
||||
class="stroke-border/70"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<polyline
|
||||
v-if="desiredHotLine"
|
||||
:points="desiredHotLine"
|
||||
fill="none"
|
||||
stroke="rgb(99 102 241)"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<polyline
|
||||
v-if="hotLine"
|
||||
:points="hotLine"
|
||||
fill="none"
|
||||
stroke="rgb(16 185 129)"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<polyline
|
||||
v-if="inFlightLine"
|
||||
:points="inFlightLine"
|
||||
fill="none"
|
||||
stroke="rgb(245 158 11)"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<polyline
|
||||
v-if="emaLine"
|
||||
:points="emaLine"
|
||||
fill="none"
|
||||
stroke="rgb(14 165 233)"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
v-for="burst in burstPoints"
|
||||
:key="`${burst.x}-${burst.y}`"
|
||||
:cx="burst.x"
|
||||
:cy="burst.y"
|
||||
r="3"
|
||||
fill="rgb(239 68 68)"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ firstSampleTime }}</span>
|
||||
<span>峰值 {{ maxChartValueText }}</span>
|
||||
<span>{{ lastSampleTime }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
export interface PoolDemandMetricSample {
|
||||
providerId: string
|
||||
sampledAt: number
|
||||
hotCount: number
|
||||
desiredHot: number
|
||||
inFlight: number
|
||||
emaInFlight: number
|
||||
burstPending: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerName?: string | null
|
||||
samples: PoolDemandMetricSample[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const chartWidth = 640
|
||||
const chartHeight = 220
|
||||
const yTickRatios = [0, 0.25, 0.5, 0.75, 1]
|
||||
|
||||
const latest = computed(() => props.samples.at(-1) ?? null)
|
||||
|
||||
const maxChartValue = computed(() => {
|
||||
const maxValue = props.samples.reduce((max, sample) => {
|
||||
return Math.max(
|
||||
max,
|
||||
sample.hotCount,
|
||||
sample.desiredHot,
|
||||
sample.inFlight,
|
||||
sample.emaInFlight,
|
||||
)
|
||||
}, 1)
|
||||
return Math.max(1, Math.ceil(maxValue))
|
||||
})
|
||||
|
||||
const yTicks = computed(() => {
|
||||
return yTickRatios.map(ratio => ({
|
||||
y: chartHeight * ratio,
|
||||
}))
|
||||
})
|
||||
|
||||
function formatMetric(value: number, fractionDigits = 0): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return fractionDigits > 0 ? '0.0' : '0'
|
||||
}
|
||||
return value.toFixed(fractionDigits)
|
||||
}
|
||||
|
||||
function formatSampleTime(timestamp: number | undefined): string {
|
||||
if (!timestamp) return '--:--:--'
|
||||
const date = new Date(timestamp)
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
function buildLine(valueOf: (sample: PoolDemandMetricSample) => number): string {
|
||||
if (props.samples.length === 0) return ''
|
||||
const maxValue = maxChartValue.value
|
||||
const widthStep = props.samples.length > 1
|
||||
? chartWidth / (props.samples.length - 1)
|
||||
: 0
|
||||
return props.samples
|
||||
.map((sample, index) => {
|
||||
const x = props.samples.length > 1 ? index * widthStep : chartWidth / 2
|
||||
const normalized = Math.max(0, Math.min(valueOf(sample), maxValue))
|
||||
const y = chartHeight - ((normalized / maxValue) * chartHeight)
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const hotLine = computed(() => buildLine(sample => sample.hotCount))
|
||||
const desiredHotLine = computed(() => buildLine(sample => sample.desiredHot))
|
||||
const inFlightLine = computed(() => buildLine(sample => sample.inFlight))
|
||||
const emaLine = computed(() => buildLine(sample => sample.emaInFlight))
|
||||
|
||||
const burstPoints = computed(() => {
|
||||
if (props.samples.length === 0) return []
|
||||
const widthStep = props.samples.length > 1
|
||||
? chartWidth / (props.samples.length - 1)
|
||||
: 0
|
||||
const maxValue = maxChartValue.value
|
||||
return props.samples
|
||||
.map((sample, index) => {
|
||||
if (!sample.burstPending) return null
|
||||
const x = props.samples.length > 1 ? index * widthStep : chartWidth / 2
|
||||
const normalized = Math.max(0, Math.min(sample.desiredHot, maxValue))
|
||||
const y = chartHeight - ((normalized / maxValue) * chartHeight)
|
||||
return { x, y }
|
||||
})
|
||||
.filter((point): point is { x: number, y: number } => point !== null)
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
const sample = latest.value
|
||||
return [
|
||||
{
|
||||
label: '热池',
|
||||
value: sample ? `${sample.hotCount} / ${sample.desiredHot}` : '0 / 0',
|
||||
hint: '当前 / 目标',
|
||||
},
|
||||
{
|
||||
label: 'in-flight',
|
||||
value: sample ? formatMetric(sample.inFlight) : '0',
|
||||
hint: '正在执行',
|
||||
},
|
||||
{
|
||||
label: 'EMA',
|
||||
value: sample ? formatMetric(sample.emaInFlight, 1) : '0.0',
|
||||
hint: '平滑热度',
|
||||
},
|
||||
{
|
||||
label: 'Burst',
|
||||
value: sample?.burstPending ? '补热中' : '空闲',
|
||||
hint: '异步补位',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const legendItems = [
|
||||
{ label: '目标', dotClass: 'bg-indigo-500' },
|
||||
{ label: '热池', dotClass: 'bg-emerald-500' },
|
||||
{ label: 'in-flight', dotClass: 'bg-amber-500' },
|
||||
{ label: 'EMA', dotClass: 'bg-sky-500' },
|
||||
{ label: 'Burst', dotClass: 'bg-red-500' },
|
||||
]
|
||||
|
||||
const sampleWindowText = computed(() => {
|
||||
const count = props.samples.length
|
||||
if (count === 0) return '等待下一次采样'
|
||||
return `最近 ${count} 个采样点`
|
||||
})
|
||||
|
||||
const firstSampleTime = computed(() => formatSampleTime(props.samples[0]?.sampledAt))
|
||||
const lastSampleTime = computed(() => formatSampleTime(latest.value?.sampledAt))
|
||||
const maxChartValueText = computed(() => formatMetric(maxChartValue.value))
|
||||
</script>
|
||||
@@ -12,6 +12,7 @@ describe('poolAdvancedDialog', () => {
|
||||
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
|
||||
'health_policy_enabled',
|
||||
'probing_enabled',
|
||||
'account_self_check_enabled',
|
||||
'auto_remove_banned_keys',
|
||||
'skip_exhausted_accounts',
|
||||
])
|
||||
@@ -27,7 +28,12 @@ describe('poolAdvancedDialog', () => {
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
description: '自动维护热池,缺口时异步补位。',
|
||||
},
|
||||
{
|
||||
key: 'account_self_check_enabled',
|
||||
label: '账号自检',
|
||||
description: '定时确认封号状态,默认刷新额度,也可使用自定义请求。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PoolHealthToggleKey =
|
||||
| 'health_policy_enabled'
|
||||
| 'probing_enabled'
|
||||
| 'account_self_check_enabled'
|
||||
| 'auto_remove_banned_keys'
|
||||
| 'skip_exhausted_accounts'
|
||||
|
||||
@@ -34,7 +35,12 @@ export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
description: '自动维护热池,缺口时异步补位。',
|
||||
},
|
||||
{
|
||||
key: 'account_self_check_enabled',
|
||||
label: '账号自检',
|
||||
description: '定时确认封号状态,默认刷新额度,也可使用自定义请求。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
号池状态
|
||||
</h3>
|
||||
@@ -21,6 +21,34 @@
|
||||
>
|
||||
{{ poolStatus.total_sticky_sessions }} 个粘性会话
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="poolStatus && poolStatus.provider_desired_hot > 0"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
热池 {{ poolStatus.provider_hot_count }} / {{ poolStatus.provider_desired_hot }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="poolStatus && poolStatus.provider_in_flight > 0"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
in-flight {{ poolStatus.provider_in_flight }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="poolStatus && poolStatus.provider_desired_hot > 0"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
EMA {{ formatEmaHeat(poolStatus.provider_ema_in_flight) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="poolStatus?.provider_burst_pending"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
补热中
|
||||
</Badge>
|
||||
</div>
|
||||
<RefreshButton
|
||||
:loading="refreshing"
|
||||
@@ -265,6 +293,11 @@ function formatTokens(tokens: number): string {
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
function formatEmaHeat(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0.0'
|
||||
return value.toFixed(1)
|
||||
}
|
||||
|
||||
function getCostBarColor(usage: number, limit: number): string {
|
||||
const ratio = usage / limit
|
||||
if (ratio >= 0.9) return 'bg-red-500'
|
||||
|
||||
@@ -146,6 +146,21 @@
|
||||
<Plug class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showAdaptiveHotPoolMetricsButton"
|
||||
class="min-w-0 flex-1 flex justify-center"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
data-testid="pool-demand-metrics-button"
|
||||
title="查看自适应热池指标"
|
||||
@click="showDemandMetricsDialog = true"
|
||||
>
|
||||
<Activity class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -293,6 +308,17 @@
|
||||
>
|
||||
<Plug class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showAdaptiveHotPoolMetricsButton"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
data-testid="pool-demand-metrics-button"
|
||||
title="查看自适应热池指标"
|
||||
@click="showDemandMetricsDialog = true"
|
||||
>
|
||||
<Activity class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
@@ -1393,6 +1419,11 @@
|
||||
:current-claude-config="selectedProviderClaudeConfig"
|
||||
@saved="handleSchedulingSaved"
|
||||
/>
|
||||
<PoolDemandMetricsDialog
|
||||
v-model="showDemandMetricsDialog"
|
||||
:provider-name="selectedProviderOverview?.provider_name"
|
||||
:samples="providerDemandMetricSamples"
|
||||
/>
|
||||
<ProviderFormDialog
|
||||
v-model="providerEditDialogOpen"
|
||||
:provider="providerToEdit"
|
||||
@@ -1451,6 +1482,7 @@ import {
|
||||
Upload,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Power,
|
||||
Database,
|
||||
KeyRound,
|
||||
@@ -1533,6 +1565,7 @@ import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoin
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
|
||||
import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue'
|
||||
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
|
||||
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
|
||||
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
|
||||
@@ -1621,11 +1654,28 @@ let selectProviderRequestId = 0
|
||||
let providerDataRequestId = 0
|
||||
let keysRequestId = 0
|
||||
let keysSearchDebounceTimer: number | null = null
|
||||
let demandMetricsPollingTimer: number | null = null
|
||||
let demandMetricsRequestId = 0
|
||||
let suppressFiltersWatch = false
|
||||
let hasHydratedInitialProviderSelection = false
|
||||
const POOL_OVERVIEW_CACHE_TTL_MS = 10 * 1000
|
||||
const POOL_KEYS_CACHE_TTL_MS = 10 * 1000
|
||||
const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const POOL_DEMAND_METRICS_SAMPLES_LIMIT = 120
|
||||
const POOL_DEMAND_METRICS_POLL_INTERVAL_MS = 10 * 1000
|
||||
|
||||
interface PoolDemandMetricSample {
|
||||
providerId: string
|
||||
sampledAt: number
|
||||
hotCount: number
|
||||
desiredHot: number
|
||||
inFlight: number
|
||||
emaInFlight: number
|
||||
burstPending: boolean
|
||||
}
|
||||
|
||||
const showDemandMetricsDialog = ref(false)
|
||||
const providerDemandMetricSamples = ref<PoolDemandMetricSample[]>([])
|
||||
const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status'], label: string }> = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '可调度' },
|
||||
@@ -1651,9 +1701,11 @@ const poolScoreProbeStatusOptions = [
|
||||
{ value: 'in_progress', label: '探测中' },
|
||||
]
|
||||
|
||||
async function loadOverview(options: { cacheTtlMs?: number } = {}) {
|
||||
async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } = {}) {
|
||||
const requestId = ++overviewRequestId
|
||||
overviewLoading.value = true
|
||||
if (!options.silent) {
|
||||
overviewLoading.value = true
|
||||
}
|
||||
try {
|
||||
const res = await getPoolOverview({ cacheTtlMs: options.cacheTtlMs ?? 0 })
|
||||
if (requestId !== overviewRequestId) return
|
||||
@@ -1711,9 +1763,11 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== overviewRequestId) return
|
||||
showError(parseApiError(err))
|
||||
if (!options.silent) {
|
||||
showError(parseApiError(err))
|
||||
}
|
||||
} finally {
|
||||
if (requestId === overviewRequestId) {
|
||||
if (requestId === overviewRequestId && !options.silent) {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
@@ -1795,6 +1849,97 @@ const selectedProviderOverview = computed<PoolOverviewItem | null>(() => {
|
||||
return poolProviders.value.find(item => item.provider_id === selectedId) || null
|
||||
})
|
||||
|
||||
const showAdaptiveHotPoolMetricsButton = computed(() => {
|
||||
if (!selectedProviderId.value) return false
|
||||
return selectedProviderConfig.value?.probing_enabled === true
|
||||
})
|
||||
|
||||
function normalizeDemandMetricNumber(value: unknown): number {
|
||||
const normalized = Number(value ?? 0)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) return 0
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildDemandMetricSample(overview: PoolOverviewItem): PoolDemandMetricSample {
|
||||
return {
|
||||
providerId: overview.provider_id,
|
||||
sampledAt: Date.now(),
|
||||
hotCount: Math.floor(normalizeDemandMetricNumber(overview.provider_hot_count)),
|
||||
desiredHot: Math.floor(normalizeDemandMetricNumber(overview.provider_desired_hot)),
|
||||
inFlight: Math.floor(normalizeDemandMetricNumber(overview.provider_in_flight)),
|
||||
emaInFlight: normalizeDemandMetricNumber(overview.provider_ema_in_flight),
|
||||
burstPending: overview.provider_burst_pending === true,
|
||||
}
|
||||
}
|
||||
|
||||
function appendDemandMetricSample(overview: PoolOverviewItem | null): void {
|
||||
if (!overview || !showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value) return
|
||||
const nextSample = buildDemandMetricSample(overview)
|
||||
const existing = providerDemandMetricSamples.value.filter(
|
||||
sample => sample.providerId === overview.provider_id,
|
||||
)
|
||||
const lastSample = existing.at(-1)
|
||||
if (
|
||||
lastSample
|
||||
&& nextSample.sampledAt - lastSample.sampledAt < 1000
|
||||
&& lastSample.hotCount === nextSample.hotCount
|
||||
&& lastSample.desiredHot === nextSample.desiredHot
|
||||
&& lastSample.inFlight === nextSample.inFlight
|
||||
&& lastSample.emaInFlight === nextSample.emaInFlight
|
||||
&& lastSample.burstPending === nextSample.burstPending
|
||||
) {
|
||||
providerDemandMetricSamples.value = existing
|
||||
return
|
||||
}
|
||||
providerDemandMetricSamples.value = [...existing, nextSample]
|
||||
.slice(-POOL_DEMAND_METRICS_SAMPLES_LIMIT)
|
||||
}
|
||||
|
||||
function stopDemandMetricsPolling(): void {
|
||||
if (demandMetricsPollingTimer !== null) {
|
||||
window.clearInterval(demandMetricsPollingTimer)
|
||||
demandMetricsPollingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDemandMetricsOverview(): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value || !providerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++demandMetricsRequestId
|
||||
try {
|
||||
const res = await getPoolOverview({ cacheTtlMs: 0 })
|
||||
if (
|
||||
requestId !== demandMetricsRequestId
|
||||
|| !showDemandMetricsDialog.value
|
||||
|| selectedProviderId.value !== providerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const allProviders = Array.isArray(res.items) ? res.items : []
|
||||
const enabledProviders = allProviders.filter(item => item.pool_enabled)
|
||||
poolProviders.value = enabledProviders
|
||||
appendDemandMetricSample(
|
||||
enabledProviders.find(item => item.provider_id === providerId) || null,
|
||||
)
|
||||
} catch {
|
||||
// 指标弹窗只做尽力刷新,失败不打断主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function startDemandMetricsPolling(): void {
|
||||
stopDemandMetricsPolling()
|
||||
appendDemandMetricSample(selectedProviderOverview.value)
|
||||
void refreshDemandMetricsOverview()
|
||||
demandMetricsPollingTimer = window.setInterval(() => {
|
||||
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value) return
|
||||
if (document.visibilityState === 'hidden') return
|
||||
void refreshDemandMetricsOverview()
|
||||
}, POOL_DEMAND_METRICS_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const poolSchedulingLabel = computed(() => {
|
||||
if (!selectedProviderConfig.value && selectedProviderOverview.value?.pool_enabled === false) {
|
||||
return '未启用'
|
||||
@@ -1864,11 +2009,63 @@ const selectedProviderStatusText = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function formatDemandEma(value: number | undefined): string {
|
||||
const normalized = Number(value ?? 0)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) return '0.0'
|
||||
return normalized.toFixed(1)
|
||||
}
|
||||
|
||||
const selectedProviderDemandMetaText = computed(() => {
|
||||
const overview = selectedProviderOverview.value
|
||||
if (!overview) return ''
|
||||
const segments: string[] = []
|
||||
const desiredHot = Number(overview.provider_desired_hot ?? 0)
|
||||
const hotCount = Number(overview.provider_hot_count ?? 0)
|
||||
const inFlight = Number(overview.provider_in_flight ?? 0)
|
||||
if (Number.isFinite(desiredHot) && desiredHot > 0) {
|
||||
segments.push(`热池 ${hotCount} / ${desiredHot}`)
|
||||
segments.push(`EMA ${formatDemandEma(overview.provider_ema_in_flight)}`)
|
||||
}
|
||||
if (Number.isFinite(inFlight) && inFlight > 0) {
|
||||
segments.push(`in-flight ${inFlight}`)
|
||||
}
|
||||
if (overview.provider_burst_pending) {
|
||||
segments.push('补热中')
|
||||
}
|
||||
return segments.join(' | ')
|
||||
})
|
||||
|
||||
const poolHeaderMetaText = computed(() => {
|
||||
const providerType = selectedProviderType.value
|
||||
const status = selectedProviderStatusText.value
|
||||
if (providerType && status) return `${providerType} | ${status}`
|
||||
return providerType || status || ''
|
||||
return [
|
||||
selectedProviderType.value,
|
||||
selectedProviderStatusText.value,
|
||||
selectedProviderDemandMetaText.value,
|
||||
].filter(Boolean).join(' | ')
|
||||
})
|
||||
|
||||
watch(showDemandMetricsDialog, (open) => {
|
||||
if (open) {
|
||||
startDemandMetricsPolling()
|
||||
} else {
|
||||
stopDemandMetricsPolling()
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedProviderId, () => {
|
||||
providerDemandMetricSamples.value = []
|
||||
if (showDemandMetricsDialog.value) {
|
||||
appendDemandMetricSample(selectedProviderOverview.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedProviderOverview, (overview) => {
|
||||
appendDemandMetricSample(overview)
|
||||
})
|
||||
|
||||
watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
|
||||
if (!enabled && showDemandMetricsDialog.value) {
|
||||
showDemandMetricsDialog.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const showAccountQuotaColumn = computed(() => {
|
||||
@@ -3955,6 +4152,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDemandMetricsPolling()
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
|
||||
@@ -125,6 +125,7 @@ vi.mock('lucide-vue-next', async () => {
|
||||
Upload: Icon,
|
||||
ChevronDown: Icon,
|
||||
RefreshCw: Icon,
|
||||
Activity: Icon,
|
||||
Power: Icon,
|
||||
Database: Icon,
|
||||
KeyRound: Icon,
|
||||
@@ -140,6 +141,8 @@ vi.mock('lucide-vue-next', async () => {
|
||||
Settings2: Icon,
|
||||
SlidersHorizontal: Icon,
|
||||
CircleHelp: Icon,
|
||||
Edit: Icon,
|
||||
Plug: Icon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -312,6 +315,17 @@ vi.mock('@/features/pool/components/PoolAdvancedDialog.vue', async () => {
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/pool/components/PoolDemandMetricsDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'PoolDemandMetricsDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/pool/components/PoolAccountBatchDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
@@ -334,6 +348,28 @@ vi.mock('@/features/pool/components/ProviderProxyPopover.vue', async () => {
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/providers/components/EndpointFormDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'EndpointFormDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/providers/components/ProviderFormDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ProviderFormDialogStub',
|
||||
setup() {
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/providers/components/KeyAllowedModelsEditDialog.vue', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
return {
|
||||
@@ -404,7 +440,7 @@ function createOverview(providerType: string): PoolOverviewItem {
|
||||
}
|
||||
}
|
||||
|
||||
function createProvider(providerType: string) {
|
||||
function createProvider(providerType: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `${providerType}-provider`,
|
||||
name: `${providerType} Provider`,
|
||||
@@ -414,6 +450,7 @@ function createProvider(providerType: string) {
|
||||
proxy: null,
|
||||
pool_advanced: null,
|
||||
claude_code_advanced: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,4 +809,37 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
expect(root.textContent).toContain('3.5K')
|
||||
expect(root.textContent).toContain('$1.25')
|
||||
})
|
||||
|
||||
it('shows adaptive hot pool metrics entry only when probing is enabled', async () => {
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({
|
||||
items: [{ ...createOverview('codex'), provider_desired_hot: 4, provider_in_flight: 2, provider_ema_in_flight: 1.8 }],
|
||||
})
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey('codex')))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
|
||||
pool_advanced: {
|
||||
probing_enabled: true,
|
||||
},
|
||||
}))
|
||||
|
||||
const enabledRoot = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(enabledRoot.querySelectorAll('[data-testid="pool-demand-metrics-button"]').length).toBeGreaterThan(0)
|
||||
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
|
||||
pool_advanced: {
|
||||
probing_enabled: false,
|
||||
},
|
||||
}))
|
||||
|
||||
const disabledRoot = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(disabledRoot.querySelector('[data-testid="pool-demand-metrics-button"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user