feat(channel):新增渠道调度状态显示
This commit is contained in:
1 parent
bc2f202f4f
commit
540817a6a7
6 files changed
+170
-20
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user