feat(channel):渠道详情页显示模型监控信息

This commit is contained in:
chengqian committed 2026-08-19 18:34:21 +08:00
1 parent 949957755e
commit 330d02f19d
4 files changed
+225 -9

No files matched your search

+8 -2
View File
@@ -180,6 +180,11 @@ type AbilityItem struct {
CostRatio float64 `json:"cost_ratio" d:"1" v:"between:0,100" dc:"成本比例:上游实际价/平台基准价,1.0=等价(参与调度 costFactor)"`
SupportsResponses bool `json:"supports_responses" d:"false" dc:"支持 OpenAI Responses 协议(/v1/responses 原生直连,responses 入站软偏好)"`
ChatViaResponses bool `json:"chat_via_responses" d:"false" dc:"上游仅有 Responses 协议(responses-only),chat 入站经桥接发送 /v1/responses"`
// 模型级健康状态(从 Redis 实时读取)
HealthScore *float64 `json:"health_score" dc:"健康分 0-100,null=无数据"`
SuccEWMA *float64 `json:"succ_ewma" dc:"成功率 EWMA 0-1"`
LatencyEWMA *float64 `json:"latency_ewma" dc:"延迟 EWMA (ms)"`
BreakerState *int `json:"breaker_state" dc:"熔断状态:0=正常 1=熔断 2=半开,null=无熔断器"`
}
// ProviderDefaultURLReq 获取供应商默认 URL
@@ -282,8 +287,9 @@ type ChannelCloneRes struct {
// ChannelResetHealthReq 重置渠道健康度请求
type ChannelResetHealthReq struct {
g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"`
ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"`
g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"`
ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"`
ModelName string `json:"model_name" dc:"可选:指定模型名则只重置该模型的健康度,留空则重置整个渠道(含所有模型)"`
}
// ChannelResetHealthRes 重置渠道健康度响应
+8
View File
@@ -104,6 +104,14 @@ func ResetChannelHealth(ctx context.Context, channelID int64) {
redisState.ResetHealth(ctx, channelID, models)
}
// ResetModelHealth 重置指定模型的健康度(只重置该模型,不影响其他模型)
func ResetModelHealth(ctx context.Context, channelID int64, modelName string) {
if redisState == nil {
return
}
redisState.ResetHealth(ctx, channelID, []string{modelName})
}
// InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。
// 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。
func InvalidateChannel(ctx context.Context, channelID int64) {
+137 -2
View File
@@ -692,9 +692,10 @@ func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbiliti
return nil, err
}
list := make([]v1.AbilityItem, len(abilities))
// 转换为具名类型以传递给 batchGetModelHealth
abilityRecords := make([]abilityRecord, len(abilities))
for i, a := range abilities {
list[i] = v1.AbilityItem{
abilityRecords[i] = abilityRecord{
ID: a.ID,
ModelName: a.ModelName,
UpstreamModel: a.UpstreamModel,
@@ -705,9 +706,121 @@ func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbiliti
}
}
// 批量读取模型级健康状态(从 Redis)
healthMap := s.batchGetModelHealth(ctx, req.ChannelID, abilityRecords)
list := make([]v1.AbilityItem, len(abilities))
for i, a := range abilities {
item := v1.AbilityItem{
ID: a.ID,
ModelName: a.ModelName,
UpstreamModel: a.UpstreamModel,
Enabled: a.Enabled,
CostRatio: a.CostRatio,
SupportsResponses: a.SupportsResponses,
ChatViaResponses: a.ChatViaResponses,
}
// 填充健康数据
if h, ok := healthMap[a.ModelName]; ok {
item.HealthScore = h.HealthScore
item.SuccEWMA = h.SuccEWMA
item.LatencyEWMA = h.LatencyEWMA
item.BreakerState = h.BreakerState
}
list[i] = item
}
return &v1.ChannelAbilitiesGetRes{List: list}, nil
}
// ModelHealthData 模型健康数据
type ModelHealthData struct {
HealthScore *float64
SuccEWMA *float64
LatencyEWMA *float64
BreakerState *int
}
// abilityRecord 能力记录(用于类型匹配)
type abilityRecord struct {
ID int64
ModelName string
UpstreamModel string
Enabled bool
CostRatio float64
SupportsResponses bool
ChatViaResponses bool
}
// batchGetModelHealth 批量读取模型级健康状态(从 Redis)
// Redis key 模式:
// - dispatch:v1:health:<channelID>:<model> (HASH) → succ_ewma, lat_ewma
// - dispatch:v1:breaker:<channelID>:<model> (HASH) → state, half_open_at, first_opened_ms
func (s *sAdmin) batchGetModelHealth(ctx context.Context, channelID int64, abilities []abilityRecord) map[string]*ModelHealthData {
healthMap := make(map[string]*ModelHealthData)
if len(abilities) == 0 {
return healthMap
}
redis := g.Redis()
for _, ab := range abilities {
healthKey := fmt.Sprintf("dispatch:v1:health:%d:%s", channelID, ab.ModelName)
breakerKey := fmt.Sprintf("dispatch:v1:breaker:%d:%s", channelID, ab.ModelName)
data := &ModelHealthData{}
// 读取健康分数(HGETALL health key)
healthHash, err := redis.HGetAll(ctx, healthKey)
if err == nil && !healthHash.IsEmpty() {
hashMap := healthHash.Map()
// succ_ewma: 成功率 EWMA (0-1)
if succEWMAVal, ok := hashMap["succ_ewma"]; ok && succEWMAVal != nil {
if succStr, ok := succEWMAVal.(string); ok {
var succEWMA float64
if _, err := fmt.Sscanf(succStr, "%f", &succEWMA); err == nil && succEWMA >= 0 {
data.SuccEWMA = &succEWMA
// 健康分 = succ_ewma * 100
healthScore := succEWMA * 100
data.HealthScore = &healthScore
}
}
}
// lat_ewma: 延迟 EWMA (ms)
if latEWMAVal, ok := hashMap["lat_ewma"]; ok && latEWMAVal != nil {
if latStr, ok := latEWMAVal.(string); ok {
var latEWMA float64
if _, err := fmt.Sscanf(latStr, "%f", &latEWMA); err == nil && latEWMA >= 0 {
data.LatencyEWMA = &latEWMA
}
}
}
}
// 读取熔断状态(HGET breaker key state)
stateVal, err := redis.HGet(ctx, breakerKey, "state")
if err == nil && !stateVal.IsNil() {
if stateStr := stateVal.String(); stateStr != "" {
var state int
if _, err := fmt.Sscanf(stateStr, "%d", &state); err == nil {
// 0=CLOSED(正常), 1=OPEN(熔断), 2=HALF_OPEN(半开)
data.BreakerState = &state
}
}
}
// 只有至少有一个健康指标时才加入 map
if data.HealthScore != nil || data.SuccEWMA != nil || data.LatencyEWMA != nil || data.BreakerState != nil {
healthMap[ab.ModelName] = data
}
}
return healthMap
}
// GetProviderDefaultURLs 获取供应商默认 API 地址
func (s *sAdmin) GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) {
return &v1.ProviderDefaultURLRes{URLs: defaultProviderURLs}, nil
@@ -804,6 +917,28 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea
return nil, common.NewBusinessError(404, "渠道不存在")
}
// 如果指定了模型名,只重置该模型的健康度
if req.ModelName != "" {
// 验证模型能力是否存在
abilityCount, err := dao.ChnAbilities.Ctx(ctx).
Where("channel_id", req.ID).
Where("model_name", req.ModelName).
Count()
if err != nil {
return nil, err
}
if abilityCount == 0 {
return nil, common.NewBusinessError(404, fmt.Sprintf("模型 %s 不存在于该渠道", req.ModelName))
}
// 调度层:只复位指定模型的熔断 + 成功率恢复
dispatchadapter.ResetModelHealth(ctx, req.ID, req.ModelName)
return nil, nil
}
// 未指定模型名,重置整个渠道(含所有模型)
// 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;下次维护快照会按策略重算)
affected, err := dao.ChnHealthScores.Ctx(ctx).
Where("channel_id", req.ID).
+72 -5
View File
@@ -132,7 +132,9 @@ const abilitiesData = ref<any[]>([])
const abilityColumns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '平台模型名', dataIndex: 'model_name', width: 200 },
{ title: '平台模型名', dataIndex: 'model_name', width: 180 },
// 配置操作列
{
title: '上游模型名', dataIndex: 'upstream_model', width: 200,
render({ record }) {
@@ -189,12 +191,40 @@ const abilityColumns: TableColumnData[] = [
}, () => record.enabled ? '启用' : '禁用')
},
},
// 健康监控列(放在右侧,操作按钮前面)
{
title: '操作', dataIndex: 'actions', width: 80, fixed: 'right',
title: '健康状态', dataIndex: 'health_score', width: 120,
render({ record }) {
return h(Popconfirm, { content: '确定移除该能力?', onOk: () => handleDeleteAbility(record.id) }, () =>
h(Button, { size: 'mini', status: 'danger' }, () => '移除')
)
return renderHealthBadge(record)
},
},
{
title: '熔断', dataIndex: 'breaker_state', width: 80,
render({ record }) {
if (record.breaker_state === null || record.breaker_state === undefined) {
return h('span', { style: 'color: var(--color-text-4); font-size: 12px' }, '—')
}
const color = breakerTagColor[record.breaker_state]
const label = breakerLabel[record.breaker_state]
return h(Tag, { color, size: 'small' }, () => label)
},
},
// 操作列(固定在最右侧)
{
title: '操作', dataIndex: 'actions', width: 150, fixed: 'right',
render({ record }) {
return h('div', { style: 'display: flex; gap: 8px' }, [
h(Button, {
size: 'mini',
onClick: () => handleResetModelHealth(record),
disabled: !record.health_score && record.breaker_state === null,
}, () => '重置健康'),
h(Popconfirm, { content: '确定移除该能力?', onOk: () => handleDeleteAbility(record.id) }, () =>
h(Button, { size: 'mini', status: 'danger' }, () => '移除')
),
])
},
},
]
@@ -273,6 +303,43 @@ async function handleDeleteAbility(id: number) {
} catch { /* error handled by interceptor */ }
}
// 新增:健康徽章渲染函数
function renderHealthBadge(record: any) {
if (record.health_score === null || record.health_score === undefined) {
return h('div', { style: 'display: flex; align-items: center; gap: 6px' }, [
h('div', {
style: 'width: 8px; height: 8px; border-radius: 50%; background: #94a3b8'
}),
h('span', { style: 'color: var(--color-text-3); font-size: 13px' }, '无数据'),
])
}
const score = Math.round(record.health_score)
const color = healthColor(score)
const statusText = score >= 80 ? '健康' : score >= 50 ? '降级' : '故障'
return h('div', { style: 'display: flex; align-items: center; gap: 6px' }, [
h('div', {
style: `width: 8px; height: 8px; border-radius: 50%; background: ${color}`
}),
h('span', { style: `color: ${color}; font-weight: 500; font-size: 13px` }, statusText),
h('span', { style: 'color: var(--color-text-3); font-size: 12px; margin-left: 2px' }, `(${score})`),
])
}
// 新增:重置模型级健康
async function handleResetModelHealth(record: any) {
try {
await request.post(`/admin/channels/${channelId}/reset_health`, {
model_name: record.model_name,
})
Message.success(`模型 ${record.model_name} 健康已重置`)
fetchAbilities()
} catch {
// error handled by interceptor
}
}
// Add Ability Modal
const showAddAbilityModal = ref(false)
const addAbilityForm = reactive({ model_name: '', upstream_model: '', enabled: true })