feat(channel):新增渠道调度状态显示

This commit is contained in:
qian committed 2026-08-08 09:09:07 +08:00
1 parent bc2f202f4f
commit 540817a6a7
6 files changed
+170 -20

No files matched your search

+4
View File
@@ -45,6 +45,8 @@ type ChannelItem struct {
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
CreatedAt string `json:"created_at"`
HealthScore *float64 `json:"health_score"`
BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活
BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总)
}
// ChannelCreateReq 创建渠道请求
@@ -131,6 +133,8 @@ type ChannelDetailRes struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
HealthScore *float64 `json:"health_score"`
BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活
BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总)
KeyType string `json:"key_type"`
KeyStatus string `json:"key_status"`
KeyName string `json:"key_name"`
+35
View File
@@ -147,6 +147,41 @@ func (c *Catalog) ChannelModels() map[int64][]string {
return out
}
// ChannelRuntimeState 单个渠道的调度运行状态聚合(管理后台展示用)。
// 数据来源:目录内存快照(每 5s 刷新),与调度器 buildCandidates 使用同一份数据;
// Redis 读值已在 Rebuild 时经 EffectiveBreakerState 做惰性 OPEN→HALF_OPEN 判定。
type ChannelRuntimeState struct {
Breaker dispatch.BreakerState // 渠道级熔断状态(同一渠道所有模型取值相同)
BreakerModels int // 处于 OPEN/HALF_OPEN 的模型数量(渠道×模型级汇总)
}
// ChannelRuntimeStates 返回目录快照中各渠道的熔断聚合视图。
// 遍历 byModel 按渠道 ID 去重;渠道级 Breaker 取首次遇到值(各模型行相同),
// 模型级 ModelBreaker 按行计数(同一渠道在 byModel 中每个模型至多出现一次 → 计数即按模型去重)。
// 注意:disabled / 无 enabled 能力的渠道不在快照(loadCatalogFromDB 仅载 c.status='active'),
// 不会出现在返回 map 中——调用方需按运营状态自行区分展示。
// 并发安全:c.current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。
func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState {
idx := c.current.Load()
if idx == nil {
return nil
}
out := make(map[int64]ChannelRuntimeState)
for _, chans := range idx.byModel {
for _, ch := range chans {
agg, ok := out[ch.ID]
if !ok {
agg = ChannelRuntimeState{Breaker: ch.Breaker}
}
if ch.ModelBreaker != dispatch.BreakerClosed {
agg.BreakerModels++
}
out[ch.ID] = agg
}
}
return out
}
// Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。
func (c *Catalog) Invalidate() {
select {
+43
View File
@@ -148,3 +148,46 @@ func TestRampElapsed(t *testing.T) {
// 窗口未配置:不爬坡
assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0))
}
// TestCatalog_渠道运行状态聚合 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
// 模型级熔断按模型计数。
func TestCatalog_渠道运行状态聚合(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"},
{ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"},
{ChannelID: 3, ChannelName: "C", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"},
}
c := testCatalog(rows, nil, func(_ context.Context, channelID int64, _ string) RuntimeReadout {
switch channelID {
case 1:
return RuntimeReadout{Breaker: dispatch.BreakerOpen, ModelBreaker: dispatch.BreakerOpen}
case 2:
return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerHalfOpen}
default:
return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerClosed}
}
})
got := c.ChannelRuntimeStates()
require.Len(t, got, 3)
// 渠道 1:渠道级熔断 OPEN,两个模型均熔断
assert.Equal(t, dispatch.BreakerOpen, got[1].Breaker)
assert.Equal(t, 2, got[1].BreakerModels, "两个模型均熔断")
// 渠道 2:渠道级正常,仅 gpt-4o 模型半开
assert.Equal(t, dispatch.BreakerClosed, got[2].Breaker)
assert.Equal(t, 1, got[2].BreakerModels, "仅 gpt-4o 半开")
// 渠道 3:完全正常
assert.Equal(t, dispatch.BreakerClosed, got[3].Breaker)
assert.Equal(t, 0, got[3].BreakerModels)
}
// TestCatalog_运行状态_未Rebuild返回空 验证目录未构建时返回空 map(而非 nil),
// 调用方遍历时安全。
func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) {
c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil)
assert.Empty(t, c.ChannelRuntimeStates())
}
+22 -2
View File
@@ -44,6 +44,16 @@ var providerTypeNames = map[int]string{
42: "Sub2API",
}
// channelRuntimeStates 返回目录快照的渠道熔断聚合(管理后台列表/详情展示用)。
// 目录未初始化(首次 relay 请求前)时返回 nil,调用方按「正常」处理。
func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState {
cat := dispatchadapter.CatalogInstance()
if cat == nil {
return nil
}
return cat.ChannelRuntimeStates()
}
// ListChannels 获取渠道列表
func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) {
query := dao.ChnChannels.Ctx(ctx).
@@ -104,6 +114,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.
}
list := make([]v1.ChannelItem, 0, len(channels))
runtimeStates := channelRuntimeStates()
for _, ch := range channels {
typeName := providerTypeNames[ch.Type]
if typeName == "" {
@@ -112,7 +123,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.
settings := relay.ParseChannelSettings(ch.Settings)
list = append(list, v1.ChannelItem{
item := v1.ChannelItem{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
@@ -133,7 +144,12 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
CreatedAt: ch.CreatedAt.String(),
HealthScore: ch.HealthScore,
})
}
if rs, ok := runtimeStates[ch.ID]; ok {
item.BreakerState = int(rs.Breaker)
item.BreakerModels = rs.BreakerModels
}
list = append(list, item)
}
return &v1.ChannelListRes{
@@ -523,6 +539,10 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
KeyStatus: keyInfo.Status,
KeyName: keyInfo.Name,
}
if rs, ok := channelRuntimeStates()[req.ID]; ok {
res.BreakerState = int(rs.Breaker)
res.BreakerModels = rs.BreakerModels
}
if res.KeyType == "" {
res.KeyType = "apikey"
}
@@ -19,6 +19,8 @@ const activeTab = ref('info')
const statusTagColor: Record<string, string> = { active: 'green', disabled: 'orangered', testing: 'arcoblue' }
const statusLabel: Record<string, string> = { active: '启用', disabled: '禁用', testing: '测试中' }
const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'orange' }
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
const tierTagColor: Record<string, string> = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' }
const tierOptions = [
@@ -530,6 +532,18 @@ function formatHeaders(headers: Record<string, string>): string {
</span>
<span v-else style="color: #94a3b8">N/A</span>
</ADescriptionsItem>
<ADescriptionsItem label="调度状态">
<!-- disabled / testing 渠道不在目录快照中,熔断状态无意义,置灰展示 -->
<template v-if="detail.status === 'active'">
<ATag :color="breakerTagColor[detail.breaker_state ?? 0]" size="small">
{{ breakerLabel[detail.breaker_state ?? 0] }}
</ATag>
<span v-if="(detail.breaker_models ?? 0) > 0" style="margin-left: 6px; color: #f59e0b; font-size: 12px; font-weight: 600">
{{ detail.breaker_models }} 个模型受影响
</span>
</template>
<span v-else style="color: #94a3b8">—</span>
</ADescriptionsItem>
<ADescriptionsItem label="VIP">
<ATag v-if="detail.is_vip" color="gold" size="small">VIP</ATag>
<span v-else>-</span>
+52 -18
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import { Tag, Button, Space, Popconfirm, Message, Radio, InputNumber, Select } from '@arco-design/web-vue'
import { Tag, Button, Space, Popconfirm, Message, Radio, InputNumber, Select, Tooltip } from '@arco-design/web-vue'
import { IconInfoCircle } from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
@@ -36,6 +36,17 @@ const statusOptions = [
]
const statusTagColor: Record<string, string> = { active: 'green', disabled: 'orangered', testing: 'arcoblue' }
const statusLabel: Record<string, string> = { active: '启用', disabled: '禁用', testing: '测试中' }
const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'orange' }
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
// 调度状态悬浮说明:渠道级熔断 / 半开探活 / 模型级熔断的语义区分。
function breakerTooltip(state: number, models: number): string {
if (state === 1) return '渠道级熔断中:该渠道已被调度器硬排除,请求会路由到其他渠道。上游修复后可点击「重置健康度」立即复位熔断。'
if (state === 2) return '渠道半开探活中:冷却期已过,调度器按窗口放行少量真实请求验证上游恢复,探测成功即自动闭合。'
if (models > 0) return `渠道本体正常,但 ${models} 个模型处于熔断/半开:这些模型的请求会被排除或进入探测限流。`
return '调度运行正常,无熔断。'
}
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
const tierTagColor: Record<string, string> = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' }
const tierOptions = [
@@ -70,23 +81,6 @@ const columns: TableColumnData[] = [
title: '状态', dataIndex: 'status', width: 80,
render({ record }) { return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusLabel[record.status] || record.status) },
},
{
title: '权重', dataIndex: 'weight', width: 100,
render({ record }) {
// 无编辑权限时只读展示
if (!hasPermission('channel:edit')) return h('span', {}, record.weight)
return h(InputNumber, {
modelValue: record.weight,
min: 0,
max: 100,
size: 'small',
disabled: record._saving,
style: 'width: 84px',
'onUpdate:modelValue': (val: number | undefined) => { record.weight = val ?? 0 },
onChange: (val: number | undefined) => onWeightChange(record, val),
})
},
},
{
title: '层级', dataIndex: 'tier', width: 120,
render({ record }) {
@@ -108,6 +102,23 @@ const columns: TableColumnData[] = [
})
},
},
{
title: '权重', dataIndex: 'weight', width: 100,
render({ record }) {
// 无编辑权限时只读展示
if (!hasPermission('channel:edit')) return h('span', {}, record.weight)
return h(InputNumber, {
modelValue: record.weight,
min: 0,
max: 100,
size: 'small',
disabled: record._saving,
style: 'width: 84px',
'onUpdate:modelValue': (val: number | undefined) => { record.weight = val ?? 0 },
onChange: (val: number | undefined) => onWeightChange(record, val),
})
},
},
{
title: '健康度', dataIndex: 'health_score', width: 90,
render({ record }) {
@@ -115,6 +126,29 @@ const columns: TableColumnData[] = [
return h('span', { style: { color: healthColor(record.health_score), fontWeight: 600 } }, record.health_score.toFixed(0))
},
},
{
title: '调度状态', dataIndex: 'breaker_state', width: 110,
render({ record }) {
// disabled / testing 渠道不在目录快照中,熔断状态无意义,置灰展示
if (record.status !== 'active') {
return h('span', { style: 'color:#94a3b8' }, '—')
}
const state = record.breaker_state ?? 0
const models = record.breaker_models ?? 0
const tag = h(Tag, { color: breakerTagColor[state], size: 'small' }, () => breakerLabel[state])
const badge = models > 0
? h('span', { style: 'font-size:12px;color:#f59e0b;margin-left:4px;font-weight:600' }, `${models}`)
: null
const trigger = h('span', { style: 'display:inline-flex;gap:4px;align-items:center;cursor:help' }, [tag, badge])
const content = () => h('div', { style: 'line-height:1.8' }, [
h('div', null, breakerTooltip(state, models)),
(state === 1 || state === 2 || models > 0) && hasPermission('channel:edit')
? h('a', { style: 'color:#165dff;cursor:pointer', onClick: () => resetHealth(record) }, '重置健康度')
: null,
])
return h(Tooltip, null, { default: () => trigger, content })
},
},
{
title: 'VIP', dataIndex: 'is_vip', width: 70,
render({ record }) {