feat: 运行期渠道冷却可见并可手动恢复

运行期冷却只存在网关进程内存里,DB 的 health_status 看不见它:渠道被踢到
30s 冷却后,管理后台仍显示 healthy,模型也照常出现在 /v1/models,但请求
返回 503 no_channel,管理员只能去翻日志。

- channel.Service 增 RuntimeState(连续失败次数 + 冷却剩余)与 ClearRuntime;
  Gateway 包装出 ChannelRuntime / RecoverChannel 供管理端调用。
- GET /api/v1/admin/channels 每项返回 runtime_fails 与 cooldown_seconds。
- 新增 POST /api/v1/admin/channels/:id/recover:清运行期冷却与失败计数,并把
  探测写的 health_status 置回 healthy;不改 enabled,上游若真挂着,下一次探测
  或再连续 3 次请求失败会重新把它踢掉。
- 渠道页健康列下方显示「冷却中 Ns」/「连续失败 n/3」,仅在冷却或探测不健康时
  出现「立即恢复」按钮(卡片与桌面表格两套布局都覆盖)。

仍未处理:冷却到期是裸恢复、没有 half-open 试探;计数是进程内的,多实例各自
为战,要共享得等 Redis 那一步。
This commit is contained in:
Sakurasan committed 2026-10-17 01:19:03 +08:00
1 parent e865f69a41
commit d4c5263f1e
8 files changed
+166 -2

No files matched your search

+51 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack, PhArrowsClockwise } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
@@ -127,6 +127,30 @@ async function testChannel(ch: Channel) {
}
}
// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示
function runtimeHint(ch: Channel): string {
if (ch.cooldown_seconds > 0) return `冷却中 ${ch.cooldown_seconds}s`
if (ch.runtime_fails > 0) return `连续失败 ${ch.runtime_fails}/3`
return ''
}
function needRecover(ch: Channel): boolean {
return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy'
}
async function recoverChannel(ch: Channel) {
busyId.value = ch.id
try {
await http.post(`/admin/channels/${ch.id}/recover`)
toast.ok(`渠道 ${ch.name} 已恢复`)
} catch (e) {
toast.err(errMsg(e))
} finally {
busyId.value = null
await load()
}
}
onMounted(load)
</script>
@@ -159,6 +183,9 @@ onMounted(load)
{{ ch.health_status }}
</Badge>
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
<Badge v-if="runtimeHint(ch)" :variant="ch.cooldown_seconds > 0 ? 'err' : 'warn'">
{{ runtimeHint(ch) }}
</Badge>
</div>
</div>
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
@@ -167,6 +194,15 @@ onMounted(load)
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button
v-if="needRecover(ch)"
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
:disabled="busyId === ch.id"
@click="recoverChannel(ch)"
>
<PhArrowsClockwise :size="13" />
立即恢复
</button>
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
<PhStack :size="13" />
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
@@ -228,6 +264,11 @@ onMounted(load)
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<div
v-if="runtimeHint(ch)"
class="mt-1 font-mono text-[11px]"
:class="ch.cooldown_seconds > 0 ? 'text-err' : 'text-warn'"
>{{ runtimeHint(ch) }}</div>
</td>
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
@@ -236,6 +277,15 @@ onMounted(load)
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button
v-if="needRecover(ch)"
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
:disabled="busyId === ch.id"
@click="recoverChannel(ch)"
>
<PhArrowsClockwise :size="13" />
立即恢复
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<PhNotePencil :size="13" />
编辑