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>