feat(channel):渠道详情页显示模型监控信息
This commit is contained in:
1 parent
949957755e
commit
330d02f19d
4 files changed
+225
-9
No files matched your search
@@ -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 重置渠道健康度响应
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user