Spaces:
Paused
Paused
File size: 15,937 Bytes
a6cca4c 395e9dc 8543971 395e9dc e6cc775 395e9dc 8543971 5aff1af a6cca4c e6cc775 8543971 a6cca4c 395e9dc a6cca4c 395e9dc a6cca4c 395e9dc 8543971 a6cca4c e6cc775 a6cca4c 395e9dc 8543971 a6cca4c 395e9dc e6cc775 395e9dc e6cc775 395e9dc e6cc775 8543971 5aff1af 8543971 5aff1af 8543971 5aff1af 8543971 a6cca4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | <template>
<div class="space-y-5">
<!-- 标题 -->
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-700 mb-1">Pool Operations</div>
<h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">账号池操作</h2>
<p class="text-sm text-ink-500 mt-1 max-w-2xl">
集中执行轮转、检查、补满、添加、清理等会直接影响账号池状态的动作。
</p>
</div>
<!-- F3 Master health banner(共享 App 级数据) -->
<MasterHealthBanner
:master-health="masterHealth"
:min-grace-until="minGraceUntil"
:loading="false"
@refresh="$emit('reload-master-health', true)" />
<div class="glass rounded-lg p-4 lg:p-5 space-y-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex items-start gap-3 min-w-0">
<div class="shrink-0 w-10 h-10 rounded-lg border flex items-center justify-center"
:class="ipv6Tone.iconWrap">
<Network class="w-4 h-4" :stroke-width="2" />
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.3em] text-ink-500 font-semibold">
IPv6 Proxy Pool
</div>
<h3 class="text-base font-bold tracking-tight mt-0.5" :class="ipv6Tone.titleClass">
{{ ipv6Tone.title }}
</h3>
<p class="text-xs text-ink-500 mt-1 max-w-3xl leading-relaxed">
{{ ipv6Subtitle }}
</p>
</div>
</div>
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs font-semibold"
:class="ipv6Tone.badgeClass">
<span class="w-1.5 h-1.5 rounded-full" :class="ipv6Tone.dotClass"></span>
{{ ipv6BadgeText }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div v-for="item in ipv6Metrics" :key="item.label"
class="rounded-lg border border-hairline bg-ink-50 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
{{ item.label }}
</div>
<div class="mt-1 text-lg font-bold tabular text-ink-950 truncate">
{{ item.value }}
</div>
</div>
</div>
<div v-if="ipv6Messages.length" class="space-y-2">
<div v-for="message in ipv6Messages" :key="message.kind + ':' + message.text"
class="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs"
:class="message.kind === 'error'
? 'bg-rose-50 border-rose-200 text-rose-800'
: 'bg-amber-50 border-amber-200 text-amber-800'">
<AlertTriangle class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<span class="min-w-0 break-words">{{ message.text }}</span>
</div>
</div>
<div v-if="ipv6Entries.length" class="rounded-lg border border-hairline overflow-hidden">
<div class="px-3 py-2 bg-ink-50 border-b border-hairline flex items-center justify-between gap-3">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
当前分配
</div>
<div v-if="ipv6HiddenEntries > 0" class="text-[11px] text-ink-500">
另有 {{ ipv6HiddenEntries }} 个
</div>
</div>
<div class="divide-y divide-hairline">
<div v-for="entry in ipv6Entries" :key="entry.email"
class="px-3 py-2.5 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] gap-2 sm:items-center text-xs">
<div class="min-w-0">
<div class="font-mono text-ink-900 truncate">{{ entry.email }}</div>
<div class="text-ink-500 font-mono truncate">{{ entry.ipv6_addr }}</div>
</div>
<div class="font-mono text-ink-600 tabular">:{{ entry.port }}</div>
<div class="inline-flex items-center gap-1.5 justify-self-start sm:justify-self-end"
:class="entry.healthy ? 'text-emerald-700' : 'text-rose-700'">
<Activity class="w-3.5 h-3.5" :stroke-width="2" />
{{ entry.healthy ? 'healthy' : 'unhealthy' }}
</div>
</div>
</div>
</div>
</div>
<div class="glass rounded-lg p-4 lg:p-5 space-y-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex items-start gap-3 min-w-0">
<div class="shrink-0 w-10 h-10 rounded-lg border flex items-center justify-center"
:class="cliproxyTone.iconWrap">
<Activity class="w-4 h-4" :stroke-width="2" />
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.3em] text-ink-500 font-semibold">
CLIProxyAPI Health
</div>
<h3 class="text-base font-bold tracking-tight mt-0.5" :class="cliproxyTone.titleClass">
{{ cliproxyTone.title }}
</h3>
<p class="text-xs text-ink-500 mt-1 max-w-3xl leading-relaxed">
{{ cliproxySubtitle }}
</p>
</div>
</div>
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs font-semibold"
:class="cliproxyTone.badgeClass">
<span class="w-1.5 h-1.5 rounded-full" :class="cliproxyTone.dotClass"></span>
{{ cliproxyBadgeText }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div v-for="item in cliproxyMetrics" :key="item.label"
class="rounded-lg border border-hairline bg-ink-50 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
{{ item.label }}
</div>
<div class="mt-1 text-lg font-bold tabular text-ink-950 truncate">
{{ item.value }}
</div>
</div>
</div>
<div v-if="cliproxyMessage" class="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs"
:class="cliproxyHealth?.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
: 'bg-amber-50 border-amber-200 text-amber-800'">
<AlertTriangle v-if="!cliproxyHealth?.ok" class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<Activity v-else class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<span class="min-w-0 break-words">{{ cliproxyMessage }}</span>
</div>
</div>
<TaskPanel
mode="pool"
:running-task="runningTask"
:admin-status="adminStatus"
:master-health="masterHealth"
:rotate-stream="rotateStream"
@task-started="$emit('task-started')"
@refresh="$emit('refresh')" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import TaskPanel from './TaskPanel.vue'
import MasterHealthBanner from './MasterHealthBanner.vue'
import { Activity, AlertTriangle, Network } from 'lucide-vue-next'
const props = defineProps({
runningTask: Object,
adminStatus: Object,
masterHealth: { type: Object, default: null },
status: { type: Object, default: null },
rotateStream: { type: Object, default: null },
})
defineEmits(['task-started', 'refresh', 'reload-master-health'])
const minGraceUntil = computed(() => {
let min = null
for (const acc of props.status?.accounts || []) {
if (acc.status === 'degraded_grace' && typeof acc.grace_until === 'number') {
if (min === null || acc.grace_until < min) min = acc.grace_until
}
}
return min
})
const ipv6Pool = computed(() => props.status?.ipv6_pool || null)
const cliproxyHealth = computed(() => props.status?.cliproxy || null)
const ipv6Preflight = computed(() => ipv6Pool.value?.preflight || {})
const ipv6Entries = computed(() => {
const entries = Array.isArray(ipv6Pool.value?.entries) ? ipv6Pool.value.entries : []
return entries.slice(0, 4)
})
const ipv6HiddenEntries = computed(() => {
const entries = Array.isArray(ipv6Pool.value?.entries) ? ipv6Pool.value.entries.length : 0
return Math.max(0, entries - ipv6Entries.value.length)
})
const ipv6Tone = computed(() => {
const pool = ipv6Pool.value
if (!pool) {
return {
title: '未上报',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (!pool.enabled && !pool.required) {
return {
title: '默认关闭',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (!pool.ok) {
return {
title: '需要处理',
titleClass: 'text-rose-700',
iconWrap: 'bg-rose-50 text-rose-700 border-rose-200',
badgeClass: 'bg-rose-50 text-rose-700 border-rose-200',
dotClass: 'bg-rose-500',
}
}
if (pool.count > 0) {
return {
title: '运行中',
titleClass: 'text-emerald-700',
iconWrap: 'bg-emerald-50 text-emerald-700 border-emerald-200',
badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-200',
dotClass: 'bg-emerald-500',
}
}
return {
title: '等待分配',
titleClass: 'text-amber-700',
iconWrap: 'bg-amber-50 text-amber-700 border-amber-200',
badgeClass: 'bg-amber-50 text-amber-700 border-amber-200',
dotClass: 'bg-amber-500',
}
})
const ipv6Subtitle = computed(() => {
const pool = ipv6Pool.value
if (!pool) return '当前 `/api/status` 尚未返回 IPv6 pool 信息。'
if (!pool.enabled && !pool.required) return '默认关闭,不启动本地代理进程,也不会改动本机网络配置。'
if (pool.required && !pool.enabled) return 'Required 已开启,但 pool 未启用;账号网络访问不会静默回退。'
if (!pool.ok) return pool.last_error || firstMessage(ipv6Preflight.value.errors) || '预检或代理健康检查未通过。'
if (pool.count > 0) return `已为 ${pool.count} 个账号分配独立出口;状态会随账号池刷新自动更新。`
return 'IPv6 pool 已启用,等待注册、登录或同步路径为账号分配出口。'
})
const ipv6BadgeText = computed(() => {
const pool = ipv6Pool.value
if (!pool) return 'unknown'
if (!pool.enabled && !pool.required) return 'disabled'
if (pool.required && !pool.ok) return 'required'
return pool.ok ? 'ok' : 'check'
})
const ipv6Metrics = computed(() => {
const pool = ipv6Pool.value || {}
return [
{ label: '分配数', value: String(pool.count ?? 0) },
{ label: '异常', value: String(pool.unhealthy_count ?? 0) },
{ label: '过期', value: String(pool.expired_count ?? 0) },
{ label: '端口使用', value: formatUsage(pool) },
]
})
const ipv6Messages = computed(() => {
const preflight = ipv6Preflight.value || {}
const errors = Array.isArray(preflight.errors) ? preflight.errors : []
const warnings = Array.isArray(preflight.warnings) ? preflight.warnings : []
const messages = errors.map((text) => ({ kind: 'error', text: formatIpv6Message(text) }))
if (ipv6Pool.value?.last_error) {
messages.push({ kind: 'error', text: ipv6Pool.value.last_error })
}
return [
...messages,
...warnings.map((text) => ({ kind: 'warning', text: formatIpv6Message(text) })),
].slice(0, 4)
})
const cliproxyTone = computed(() => {
const health = cliproxyHealth.value
if (!health) {
return {
title: '未上报',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (health.ok) {
return {
title: '可用',
titleClass: 'text-emerald-700',
iconWrap: 'bg-emerald-50 text-emerald-700 border-emerald-200',
badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-200',
dotClass: 'bg-emerald-500',
}
}
return {
title: '需要处理',
titleClass: 'text-amber-700',
iconWrap: 'bg-amber-50 text-amber-700 border-amber-200',
badgeClass: 'bg-amber-50 text-amber-700 border-amber-200',
dotClass: 'bg-amber-500',
}
})
const cliproxyBadgeText = computed(() => {
const health = cliproxyHealth.value
if (!health) return 'unknown'
if (health.cached) return health.ok ? 'cached ok' : 'cached check'
return health.ok ? 'ok' : 'check'
})
const cliproxySubtitle = computed(() => {
const health = cliproxyHealth.value
if (!health) return '当前 `/api/status` 尚未返回 CLIProxyAPI 健康信息。'
if (!health.management_api?.ok) return formatCliproxyReason(health.management_api?.reason || 'management_api_unavailable')
if (!health.provider_auth?.ok) return formatCliproxyReason(health.provider_auth?.reason || 'provider_auth_unavailable')
return `管理接口可读,Codex provider 有 ${health.provider_auth?.available ?? 0} 个可用认证候选。`
})
const cliproxyMetrics = computed(() => {
const health = cliproxyHealth.value || {}
const auth = health.provider_auth || {}
const mgmt = health.management_api || {}
return [
{ label: '认证总数', value: String(auth.total ?? 0) },
{ label: '可用', value: String(auth.available ?? 0) },
{ label: '禁用', value: String(auth.disabled ?? 0) },
{ label: '延迟', value: mgmt.latency_ms == null ? '-' : `${mgmt.latency_ms}ms` },
]
})
const cliproxyMessage = computed(() => {
const health = cliproxyHealth.value
if (!health) return ''
if (health.safe_read_only !== true) return '健康检查必须保持只读,请检查后端实现。'
if (health.error) return health.error
if (!health.management_api?.ok) return formatCliproxyReason(health.management_api?.reason)
if (!health.provider_auth?.ok) return formatCliproxyReason(health.provider_auth?.reason)
return '健康检查仅读取管理元数据,不会上传、删除或刷新远端认证文件。'
})
function firstMessage(values) {
return Array.isArray(values) && values.length ? formatIpv6Message(values[0]) : ''
}
function formatIpv6Message(value) {
const labels = {
ipv6_required_but_disabled: 'Required 已开启,但 IPv6 pool 未启用。',
missing_ipv6_prefix: '缺少 IPV6_PREFIX。',
missing_ipv6_iface: '缺少 IPV6_IFACE。',
missing_ip_command: '当前环境找不到 ip 命令。',
invalid_port_range: 'IPv6 proxy 端口范围无效。',
missing_sudo_command: '配置要求 sudo,但当前环境找不到 sudo。',
status_unavailable: 'IPv6 pool 状态暂不可用。',
}
return labels[value] || String(value || '')
}
function formatCliproxyReason(value) {
const labels = {
missing_config: '缺少 CPA_URL 或 CPA_KEY,无法读取 CLIProxyAPI 管理接口。',
request_failed: 'CLIProxyAPI 管理接口请求失败。',
non_200: 'CLIProxyAPI 管理接口返回非 200 状态。',
non_json: 'CLIProxyAPI 管理接口返回非 JSON 内容。',
invalid_files_payload: 'CLIProxyAPI 管理接口响应缺少 files 列表。',
management_api_unavailable: 'CLIProxyAPI 管理接口不可用。',
no_provider_auth: 'CLIProxyAPI 中没有 Codex provider 认证文件。',
provider_auth_all_unavailable: 'Codex provider 认证文件全部处于禁用、错误或不可用状态。',
provider_auth_has_candidates: 'Codex provider 有可用认证候选。',
health_check_exception: 'CLIProxyAPI 健康检查异常。',
}
return labels[value] || String(value || '状态暂不可用。')
}
function formatUsage(pool) {
const count = Number(pool.used_ports ?? 0)
const capacity = Number(pool.port_capacity ?? 0)
if (!capacity) return `${count}/0`
const pct = Number(pool.port_usage_ratio)
const pctText = Number.isFinite(pct) ? ` · ${(pct * 100).toFixed(0)}%` : ''
return `${count}/${capacity}${pctText}`
}
</script>
|