diff --git a/controller/channel-test.go b/controller/channel-test.go index ab12132b17..8a2e7f643d 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -862,6 +862,29 @@ func TestAllChannels(c *gin.Context) { }) } +// MonitorTestResult holds the result of a channel test for the group monitor module. +type MonitorTestResult struct { + Success bool + LatencyMs int64 + ErrorMsg string +} + +// TestChannelForMonitor runs a non-streaming test on the given channel and returns a simplified result. +func TestChannelForMonitor(channel *model.Channel, testModel string) MonitorTestResult { + tik := time.Now() + result := testChannel(channel, testModel, "", false) + latencyMs := time.Since(tik).Milliseconds() + r := MonitorTestResult{LatencyMs: latencyMs} + if result.localErr != nil { + r.ErrorMsg = result.localErr.Error() + } else if result.newAPIError != nil { + r.ErrorMsg = result.newAPIError.Error() + } else { + r.Success = true + } + return r +} + var autoTestChannelsOnce sync.Once func AutomaticallyTestChannels() { diff --git a/main.go b/main.go index 852e1a0a8f..a1f8e1b27a 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/router" + "github.com/QuantumNous/new-api/modules/group_monitor" "github.com/QuantumNous/new-api/service" _ "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -104,6 +105,7 @@ func main() { } go controller.AutomaticallyTestChannels() + go group_monitor.AutomaticallyGroupMonitor() // Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day service.StartCodexCredentialAutoRefreshTask() @@ -299,5 +301,8 @@ func InitResources() error { // Don't return error, custom OAuth is not critical } + // Migrate group monitor tables + group_monitor.Migrate() + return nil } diff --git a/modules/group_monitor/controller.go b/modules/group_monitor/controller.go new file mode 100644 index 0000000000..2e80995d9d --- /dev/null +++ b/modules/group_monitor/controller.go @@ -0,0 +1,181 @@ +package group_monitor + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +// GetGroupMonitorLogs 分页查询监控日志(管理员) +func GetGroupMonitorLogsHandler(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + groupName := c.Query("group") + startTs, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTs, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + logs, total, err := GetGroupMonitorLogs(groupName, startTs, endTs, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) +} + +// GetGroupMonitorLatestHandler 获取所有分组最新状态(管理员) +func GetGroupMonitorLatestHandler(c *gin.Context) { + logs, err := GetGroupMonitorLatest() + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, logs) +} + +// GetGroupMonitorStatsHandler 获取聚合统计(管理员) +func GetGroupMonitorStatsHandler(c *gin.Context) { + startTs, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTs, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + // 默认查询最近 1 小时 + if startTs == 0 { + startTs = common.GetTimestamp() - 3600 + } + + stats, err := GetGroupMonitorStats(startTs, endTs) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, stats) +} + +// GetGroupMonitorTimeSeriesHandler 获取时间序列数据(趋势图) +func GetGroupMonitorTimeSeriesHandler(c *gin.Context) { + groupName := c.Query("group") + startTs, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTs, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + // 默认最近 1 小时 + if startTs == 0 { + startTs = common.GetTimestamp() - 3600 + } + + logs, err := GetGroupMonitorTimeSeries(groupName, startTs, endTs) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, logs) +} + +// GetGroupMonitorConfigsHandler 获取所有分组监控配置(管理员) +func GetGroupMonitorConfigsHandler(c *gin.Context) { + configs, err := GetAllGroupMonitorConfigs() + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, configs) +} + +// SaveGroupMonitorConfigHandler 保存分组监控配置(管理员) +func SaveGroupMonitorConfigHandler(c *gin.Context) { + var cfg GroupMonitorConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request body", + }) + return + } + + if cfg.GroupName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "group_name is required", + }) + return + } + + if err := SaveGroupMonitorConfig(&cfg); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// DeleteGroupMonitorConfigHandler 删除分组监控配置(管理员) +func DeleteGroupMonitorConfigHandler(c *gin.Context) { + groupName := c.Param("group") + if groupName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "group name is required", + }) + return + } + + if err := DeleteGroupMonitorConfig(groupName); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// GetGroupMonitorStatusHandler 用户可见的简化状态 +func GetGroupMonitorStatusHandler(c *gin.Context) { + // 获取最近 1 小时的聚合统计 + startTs := common.GetTimestamp() - 3600 + stats, err := GetGroupMonitorStats(startTs, 0) + if err != nil { + common.ApiError(c, err) + return + } + + // 获取每个分组的最新记录 + latest, err := GetGroupMonitorLatest() + if err != nil { + common.ApiError(c, err) + return + } + + type GroupStatus struct { + GroupName string `json:"group_name"` + LatestLatency int64 `json:"latest_latency"` + LatestSuccess bool `json:"latest_success"` + LatestTime int64 `json:"latest_time"` + AvgLatency float64 `json:"avg_latency"` + Availability float64 `json:"availability"` // 百分比 + TotalChecks int64 `json:"total_checks"` + } + + // 构建 stats map + statsMap := make(map[string]*GroupMonitorStat) + for i := range stats { + statsMap[stats[i].GroupName] = &stats[i] + } + + var result []GroupStatus + for _, log := range latest { + status := GroupStatus{ + GroupName: log.GroupName, + LatestLatency: log.LatencyMs, + LatestSuccess: log.Success, + LatestTime: log.CreatedAt, + } + if stat, ok := statsMap[log.GroupName]; ok { + status.AvgLatency = stat.AvgLatency + status.TotalChecks = stat.TotalCount + if stat.TotalCount > 0 { + status.Availability = float64(stat.SuccessCount) / float64(stat.TotalCount) * 100 + } + } + result = append(result, status) + } + common.ApiSuccess(c, result) +} diff --git a/modules/group_monitor/migrate.go b/modules/group_monitor/migrate.go new file mode 100644 index 0000000000..fcbb8d15f2 --- /dev/null +++ b/modules/group_monitor/migrate.go @@ -0,0 +1,10 @@ +package group_monitor + +import ( + "github.com/QuantumNous/new-api/model" +) + +// Migrate 执行分组监控模块的数据库迁移 +func Migrate() error { + return model.DB.AutoMigrate(&GroupMonitorLog{}, &GroupMonitorConfig{}) +} diff --git a/modules/group_monitor/model.go b/modules/group_monitor/model.go new file mode 100644 index 0000000000..c781448926 --- /dev/null +++ b/modules/group_monitor/model.go @@ -0,0 +1,208 @@ +package group_monitor + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" +) + +// GroupMonitorLog 分组监控日志 +type GroupMonitorLog struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + GroupName string `json:"group_name" gorm:"type:varchar(64);index:idx_gml_group_time,priority:1;index"` + ChannelId int `json:"channel_id" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"type:varchar(255)"` + ModelName string `json:"model_name" gorm:"type:varchar(255)"` + LatencyMs int64 `json:"latency_ms"` + Success bool `json:"success"` + ErrorMsg string `json:"error_msg" gorm:"type:text"` + CachedModel string `json:"cached_model" gorm:"type:varchar(255)"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_gml_group_time,priority:2;index"` +} + +// GroupMonitorConfig 分组监控配置(管理员为每个分组选择监控渠道) +type GroupMonitorConfig struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + GroupName string `json:"group_name" gorm:"type:varchar(64);uniqueIndex"` + ChannelId int `json:"channel_id"` // 监控的渠道 ID + TestModel string `json:"test_model" gorm:"type:varchar(255)"` // 该分组的测试模型(空则用全局默认) + Enabled bool `json:"enabled" gorm:"default:true"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (GroupMonitorLog) TableName() string { + return "group_monitor_logs" +} + +func (GroupMonitorConfig) TableName() string { + return "group_monitor_configs" +} + +// CreateGroupMonitorLog 插入一条监控日志 +func CreateGroupMonitorLog(log *GroupMonitorLog) error { + return model.DB.Create(log).Error +} + +// GetGroupMonitorLogs 分页查询日志 +func GetGroupMonitorLogs(groupName string, startTs, endTs int64, startIdx, pageSize int) ([]*GroupMonitorLog, int64, error) { + var logs []*GroupMonitorLog + var total int64 + + query := model.DB.Model(&GroupMonitorLog{}) + if groupName != "" { + query = query.Where("group_name = ?", groupName) + } + if startTs > 0 { + query = query.Where("created_at >= ?", startTs) + } + if endTs > 0 { + query = query.Where("created_at <= ?", endTs) + } + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Order("created_at DESC").Offset(startIdx).Limit(pageSize).Find(&logs).Error + return logs, total, err +} + +// GetGroupMonitorLatest 获取每个 group 的最新一条记录 +func GetGroupMonitorLatest() ([]*GroupMonitorLog, error) { + var logs []*GroupMonitorLog + + // 先查所有 distinct group_name + var groupNames []string + err := model.DB.Model(&GroupMonitorLog{}).Distinct().Pluck("group_name", &groupNames).Error + if err != nil { + return nil, err + } + + if len(groupNames) == 0 { + return logs, nil + } + + // 批量查询每个分组的最新 ID(兼容三种数据库) + // 使用子查询获取每个分组的最大 created_at 对应的记录 + for _, gn := range groupNames { + var log GroupMonitorLog + // 使用子查询找到最新的 created_at,然后获取该记录 + subQuery := model.DB.Model(&GroupMonitorLog{}). + Select("MAX(created_at)"). + Where("group_name = ?", gn) + err := model.DB.Where("group_name = ? AND created_at = (?)", gn, subQuery).First(&log).Error + if err != nil { + continue + } + logs = append(logs, &log) + } + return logs, nil +} + +// GroupMonitorStat 聚合统计 +type GroupMonitorStat struct { + GroupName string `json:"group_name"` + AvgLatency float64 `json:"avg_latency"` + TotalCount int64 `json:"total_count"` + SuccessCount int64 `json:"success_count"` +} + +// GetGroupMonitorStats 获取聚合统计(1 小时维度) +func GetGroupMonitorStats(startTs, endTs int64) ([]GroupMonitorStat, error) { + var stats []GroupMonitorStat + + query := model.DB.Model(&GroupMonitorLog{}) + if startTs > 0 { + query = query.Where("created_at >= ?", startTs) + } + if endTs > 0 { + query = query.Where("created_at <= ?", endTs) + } + + // 使用 GORM 的 Where("success = ?", true) 自动处理布尔值兼容 + err := query.Select(`group_name, + AVG(latency_ms) as avg_latency, + COUNT(*) as total_count`). + Group("group_name"). + Scan(&stats).Error + if err != nil { + return nil, err + } + + // 单独查询成功次数(避免 CASE WHEN 布尔值兼容问题) + for i, stat := range stats { + var count int64 + q := model.DB.Model(&GroupMonitorLog{}).Where("group_name = ? AND success = ?", stat.GroupName, true) + if startTs > 0 { + q = q.Where("created_at >= ?", startTs) + } + if endTs > 0 { + q = q.Where("created_at <= ?", endTs) + } + q.Count(&count) + stats[i].SuccessCount = count + } + + return stats, nil +} + +// CleanupGroupMonitorLogs 清理旧日志 +func CleanupGroupMonitorLogs(retainDays int) error { + threshold := common.GetTimestamp() - int64(retainDays*86400) + return model.DB.Where("created_at < ?", threshold).Delete(&GroupMonitorLog{}).Error +} + +// GetAllGroupMonitorConfigs 获取所有分组监控配置 +func GetAllGroupMonitorConfigs() ([]*GroupMonitorConfig, error) { + var configs []*GroupMonitorConfig + err := model.DB.Find(&configs).Error + return configs, err +} + +// GetEnabledGroupMonitorConfigs 获取所有启用的分组监控配置 +func GetEnabledGroupMonitorConfigs() ([]*GroupMonitorConfig, error) { + var configs []*GroupMonitorConfig + err := model.DB.Where("enabled = ?", true).Find(&configs).Error + return configs, err +} + +// SaveGroupMonitorConfig 保存/更新分组监控配置 +func SaveGroupMonitorConfig(cfg *GroupMonitorConfig) error { + cfg.UpdatedAt = common.GetTimestamp() + // Upsert by group_name + var existing GroupMonitorConfig + err := model.DB.Where("group_name = ?", cfg.GroupName).First(&existing).Error + if err != nil { + // 不存在,创建 + return model.DB.Create(cfg).Error + } + // 已存在,更新 + return model.DB.Model(&existing).Updates(map[string]interface{}{ + "channel_id": cfg.ChannelId, + "test_model": cfg.TestModel, + "enabled": cfg.Enabled, + "updated_at": cfg.UpdatedAt, + }).Error +} + +// DeleteGroupMonitorConfig 删除分组监控配置 +func DeleteGroupMonitorConfig(groupName string) error { + return model.DB.Where("group_name = ?", groupName).Delete(&GroupMonitorConfig{}).Error +} + +// GetGroupMonitorTimeSeries 获取时间序列数据(用于趋势图) +func GetGroupMonitorTimeSeries(groupName string, startTs, endTs int64) ([]*GroupMonitorLog, error) { + var logs []*GroupMonitorLog + query := model.DB.Model(&GroupMonitorLog{}) + if groupName != "" { + query = query.Where("group_name = ?", groupName) + } + if startTs > 0 { + query = query.Where("created_at >= ?", startTs) + } + if endTs > 0 { + query = query.Where("created_at <= ?", endTs) + } + err := query.Order("created_at ASC").Find(&logs).Error + return logs, err +} diff --git a/modules/group_monitor/router.go b/modules/group_monitor/router.go new file mode 100644 index 0000000000..f7f1b6f24f --- /dev/null +++ b/modules/group_monitor/router.go @@ -0,0 +1,31 @@ +package group_monitor + +import ( + "github.com/QuantumNous/new-api/middleware" + + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册分组监控相关路由 +func RegisterRoutes(apiRouter *gin.RouterGroup) { + // 管理员接口 + adminRoute := apiRouter.Group("/group/monitor") + adminRoute.Use(middleware.AdminAuth()) + { + adminRoute.GET("/logs", GetGroupMonitorLogsHandler) + adminRoute.GET("/latest", GetGroupMonitorLatestHandler) + adminRoute.GET("/stats", GetGroupMonitorStatsHandler) + adminRoute.GET("/time_series", GetGroupMonitorTimeSeriesHandler) + adminRoute.GET("/configs", GetGroupMonitorConfigsHandler) + adminRoute.POST("/configs", SaveGroupMonitorConfigHandler) + adminRoute.DELETE("/configs/:group", DeleteGroupMonitorConfigHandler) + } + + // 用户接口 + userRoute := apiRouter.Group("/group/monitor") + userRoute.Use(middleware.UserAuth()) + { + userRoute.GET("/status", GetGroupMonitorStatusHandler) + userRoute.GET("/user_time_series", GetGroupMonitorTimeSeriesHandler) + } +} diff --git a/modules/group_monitor/service.go b/modules/group_monitor/service.go new file mode 100644 index 0000000000..c95f9da2e2 --- /dev/null +++ b/modules/group_monitor/service.go @@ -0,0 +1,129 @@ +package group_monitor + +import ( + "fmt" + "math" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/model" + + "github.com/bytedance/gopkg/util/gopool" +) + +var autoGroupMonitorOnce sync.Once + +// AutomaticallyGroupMonitor 后台定时分组监控任务 +func AutomaticallyGroupMonitor() { + if !common.IsMasterNode { + return + } + autoGroupMonitorOnce.Do(func() { + for { + setting := GetGroupMonitorSetting() + if !setting.Enabled { + time.Sleep(1 * time.Minute) + continue + } + for { + setting = GetGroupMonitorSetting() + interval := time.Duration(int(math.Round(setting.IntervalMins))) * time.Minute + time.Sleep(interval) + common.SysLog("group monitor: starting group health check") + runGroupMonitor(setting) + common.SysLog("group monitor: health check finished") + _ = CleanupGroupMonitorLogs(setting.RetainDays) + if !GetGroupMonitorSetting().Enabled { + break + } + } + } + }) +} + +func runGroupMonitor(setting *GroupMonitorSetting) { + configs, err := GetEnabledGroupMonitorConfigs() + if err != nil { + common.SysError(fmt.Sprintf("group monitor: failed to get configs: %v", err)) + return + } + + for _, cfg := range configs { + testModel := cfg.TestModel + if testModel == "" { + testModel = setting.TestModel + } + + channelId := cfg.ChannelId + if channelId <= 0 { + common.SysLog(fmt.Sprintf("group monitor: group %s has no channel configured, skipping", cfg.GroupName)) + continue + } + + channel, err := model.GetChannelById(channelId, true) + if err != nil || channel == nil { + common.SysLog(fmt.Sprintf("group monitor: channel %d not found for group %s, skipping", channelId, cfg.GroupName)) + continue + } + + // 检查渠道是否被手动禁用 + if channel.Status == common.ChannelStatusManuallyDisabled { + common.SysLog(fmt.Sprintf("group monitor: channel %d is manually disabled for group %s, skipping", channelId, cfg.GroupName)) + continue + } + + // 自动回退:如果渠道不支持指定的测试模型,用渠道的第一个可用模型 + actualModel := testModel + if !channelSupportsModel(channel, testModel) { + models := channel.GetModels() + if len(models) > 0 { + actualModel = models[0] + } else { + common.SysLog(fmt.Sprintf("group monitor: channel %d has no models for group %s, skipping", channelId, cfg.GroupName)) + continue + } + } + + groupName := cfg.GroupName + ch := channel + model := actualModel + gopool.Go(func() { + testAndRecord(groupName, ch, model) + }) + + time.Sleep(common.RequestInterval) + } +} + +func channelSupportsModel(channel *model.Channel, testModel string) bool { + models := channel.GetModels() + for _, m := range models { + if strings.TrimSpace(m) == testModel { + return true + } + } + return false +} + +func testAndRecord(groupName string, channel *model.Channel, testModel string) { + result := controller.TestChannelForMonitor(channel, testModel) + + log := &GroupMonitorLog{ + GroupName: groupName, + ChannelId: channel.Id, + ChannelName: channel.Name, + ModelName: testModel, + LatencyMs: result.LatencyMs, + Success: result.Success, + ErrorMsg: result.ErrorMsg, + CreatedAt: common.GetTimestamp(), + } + + err := CreateGroupMonitorLog(log) + if err != nil { + common.SysError(fmt.Sprintf("group monitor: failed to save log for group %s: %v", groupName, err)) + } +} diff --git a/modules/group_monitor/setting.go b/modules/group_monitor/setting.go new file mode 100644 index 0000000000..6497bbb1b9 --- /dev/null +++ b/modules/group_monitor/setting.go @@ -0,0 +1,28 @@ +package group_monitor + +import ( + "github.com/QuantumNous/new-api/setting/config" +) + +// GroupMonitorSetting 分组监控全局配置 +type GroupMonitorSetting struct { + Enabled bool `json:"enabled"` // 全局开关 + IntervalMins float64 `json:"interval_mins"` // 探测间隔(分钟) + TestModel string `json:"test_model"` // 全局默认测试模型 + RetainDays int `json:"retain_days"` // 日志保留天数 +} + +var groupMonitorSetting = GroupMonitorSetting{ + Enabled: false, + IntervalMins: 5, + TestModel: "gpt-4o-mini", + RetainDays: 7, +} + +func init() { + config.GlobalConfig.Register("group_monitor_setting", &groupMonitorSetting) +} + +func GetGroupMonitorSetting() *GroupMonitorSetting { + return &groupMonitorSetting +} diff --git a/router/api-router.go b/router/api-router.go index e2ef2f531b..5d8691bc67 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -3,6 +3,7 @@ package router import ( "github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/modules/group_monitor" // Import oauth package to register providers via init() _ "github.com/QuantumNous/new-api/oauth" @@ -290,6 +291,8 @@ func SetApiRouter(router *gin.Engine) { groupRoute.GET("/", controller.GetGroups) } + group_monitor.RegisterRoutes(apiRouter) + prefillGroupRoute := apiRouter.Group("/prefill_group") prefillGroupRoute.Use(middleware.AdminAuth()) { diff --git a/web/src/App.jsx b/web/src/App.jsx index 91c4d1a1e0..931d19de53 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -45,6 +45,8 @@ import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; +import GroupMonitorAdmin from './modules/group-monitor/GroupMonitorAdmin'; +import GroupMonitorUser from './modules/group-monitor/GroupMonitorUser'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import Setup from './pages/Setup'; @@ -102,6 +104,22 @@ function App() { } /> } /> + + + + } + /> + + + + } + /> {} }) => { className: localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', }, + { + text: t('分组状态'), + itemKey: 'group-status', + to: '/group-status', + }, ]; // 根据配置过滤项目 @@ -153,6 +160,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/channel', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('分组监控'), + itemKey: 'group-monitor', + to: '/group-monitor', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('订阅管理'), itemKey: 'subscription', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index ecc252cfde..70b68e004b 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -75,6 +75,8 @@ import { Package, Server, CalendarClock, + Activity, + ShieldCheck, } from 'lucide-react'; // 获取侧边栏Lucide图标组件 @@ -122,6 +124,10 @@ export function getLucideIcon(key, selected = false) { return ; case 'setting': return ; + case 'group-monitor': + return ; + case 'group-status': + return ; default: return ; } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8b2b085295..a90e29a98f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2856,6 +2856,33 @@ "缓存写": "Cache Write", "写": "Write", "根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Per Anthropic conventions, /v1/messages input tokens count only non-cached input and exclude cache read/write tokens.", + "分组监控": "Group Monitor", + "分组监控配置": "Group Monitor Config", + "分组状态": "Group Status", + "分组概览": "Group Overview", + "启用分组监控": "Enable Group Monitor", + "监控间隔": "Monitor Interval", + "监控日志": "Monitor Logs", + "监控渠道": "Monitor Channel", + "延迟": "Latency", + "延迟趋势": "Latency Trend", + "可用率": "Availability", + "缓存模型": "Cached Model", + "测试模型": "Test Model", + "默认测试模型": "Default Test Model", + "日志保留天数": "Log Retention Days", + "平均延迟": "Avg Latency", + "正常": "Normal", + "异常": "Abnormal", + "暂无监控数据": "No monitoring data", + "最近1小时": "Last 1 hour", + "请选择渠道": "Please select a channel", + "使用全局默认": "Use global default", + "留空使用全局默认": "Leave empty to use global default", + "确定删除?": "Confirm delete?", + "添加/更新": "Add/Update", + "筛选分组": "Filter by group", + "错误信息": "Error Message", "设计版本": "b80c3466cb6feafeb3990c7820e10e50" } } diff --git a/web/src/modules/group-monitor/GroupMonitorAdmin.jsx b/web/src/modules/group-monitor/GroupMonitorAdmin.jsx new file mode 100644 index 0000000000..ec635c3661 --- /dev/null +++ b/web/src/modules/group-monitor/GroupMonitorAdmin.jsx @@ -0,0 +1,532 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, Table, Tag, Select, Button, Form, Row, Col, Switch, InputNumber, Input, Spin, Popconfirm, Typography } from '@douyinfe/semi-ui'; +import { VChart } from '@visactor/react-vchart'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess, showWarning, compareObjects } from '../../helpers'; + +const CHART_CONFIG = { mode: 'desktop-browser' }; + +const DEFAULT_SETTINGS = { + 'group_monitor_setting.enabled': false, + 'group_monitor_setting.interval_mins': 5, + 'group_monitor_setting.test_model': 'gpt-4o-mini', + 'group_monitor_setting.retain_days': 7, +}; + +const GroupMonitorAdmin = () => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + // 配置数据 + const [globalSettings, setGlobalSettings] = useState(DEFAULT_SETTINGS); + const [savedSettings, setSavedSettings] = useState({}); + const [configs, setConfigs] = useState([]); + const [groups, setGroups] = useState([]); + const [channels, setChannels] = useState([]); + + // 监控数据 + const [latestData, setLatestData] = useState([]); + const [statsData, setStatsData] = useState([]); + const [timeSeriesData, setTimeSeriesData] = useState([]); + const [logs, setLogs] = useState([]); + const [logTotal, setLogTotal] = useState(0); + const [logPage, setLogPage] = useState(1); + const [logPageSize] = useState(20); + const [logGroupFilter, setLogGroupFilter] = useState(''); + + // 新增配置表单 + const [editingConfig, setEditingConfig] = useState({ group_name: '', channel_id: 0, test_model: '', enabled: true }); + + // 加载全局设置 + const loadGlobalSettings = useCallback(async () => { + const res = await API.get('/api/option/'); + if (res.data.success) { + const opts = res.data.data; + const current = {}; + for (const key of Object.keys(DEFAULT_SETTINGS)) { + if (opts[key] !== undefined) { + if (key.includes('enabled')) { + current[key] = opts[key] === 'true'; + } else if (key.includes('interval') || key.includes('retain') || key.includes('days')) { + current[key] = parseInt(opts[key]); + } else { + current[key] = opts[key]; + } + } else { + current[key] = DEFAULT_SETTINGS[key]; + } + } + setGlobalSettings(current); + setSavedSettings(structuredClone(current)); + } + }, []); + + // 保存全局设置 + const saveGlobalSettings = async () => { + const updateArray = compareObjects(globalSettings, savedSettings); + if (!updateArray.length) { + showWarning(t('你似乎并没有修改什么')); + return; + } + setLoading(true); + const requests = updateArray.map((item) => { + const value = typeof globalSettings[item.key] === 'boolean' ? String(globalSettings[item.key]) : String(globalSettings[item.key]); + return API.put('/api/option/', { key: item.key, value }); + }); + const results = await Promise.all(requests); + if (results.some((r) => !r)) { + showError(t('部分保存失败,请重试')); + } else { + showSuccess(t('保存成功')); + setSavedSettings(structuredClone(globalSettings)); + } + setLoading(false); + }; + + // 加载分组列表 + const loadGroups = useCallback(async () => { + const res = await API.get('/api/group/'); + if (res.data.success) { + setGroups(res.data.data || []); + } + }, []); + + // 加载渠道列表 + const loadChannels = useCallback(async () => { + const res = await API.get('/api/channel/', { params: { p: 1, page_size: 100 } }); + if (res.data.success) { + setChannels(res.data.data?.items || res.data.data || []); + } + }, []); + + // 加载分组监控配置 + const loadConfigs = useCallback(async () => { + const res = await API.get('/api/group/monitor/configs'); + if (res.data.success) { + setConfigs(res.data.data || []); + } + }, []); + + // 保存分组监控配置 + const saveConfig = async () => { + if (!editingConfig.group_name) { + showError(t('请选择分组')); + return; + } + if (!editingConfig.channel_id) { + showError(t('请选择渠道')); + return; + } + const res = await API.post('/api/group/monitor/configs', editingConfig); + if (res.data.success) { + showSuccess(t('保存成功')); + loadConfigs(); + setEditingConfig({ group_name: '', channel_id: 0, test_model: '', enabled: true }); + } + }; + + // 删除分组监控配置 + const deleteConfig = async (groupName) => { + const res = await API.delete(`/api/group/monitor/configs/${groupName}`); + if (res.data.success) { + showSuccess(t('删除成功')); + loadConfigs(); + } + }; + + // 加载最新状态 + const loadLatest = useCallback(async () => { + const res = await API.get('/api/group/monitor/latest'); + if (res.data.success) { + setLatestData(res.data.data || []); + } + }, []); + + // 加载统计数据 + const loadStats = useCallback(async () => { + const res = await API.get('/api/group/monitor/stats'); + if (res.data.success) { + setStatsData(res.data.data || []); + } + }, []); + + // 加载时间序列数据 + const loadTimeSeries = useCallback(async () => { + const now = Math.floor(Date.now() / 1000); + const res = await API.get('/api/group/monitor/time_series', { + params: { start_timestamp: now - 3600 }, + }); + if (res.data.success) { + setTimeSeriesData(res.data.data || []); + } + }, []); + + // 加载日志 + const loadLogs = useCallback(async () => { + setLoading(true); + const params = { p: logPage, page_size: logPageSize }; + if (logGroupFilter) params.group = logGroupFilter; + const res = await API.get('/api/group/monitor/logs', { params }); + if (res.data.success) { + const pageData = res.data.data; + setLogs(pageData.items || []); + setLogTotal(pageData.total || 0); + } + setLoading(false); + }, [logPage, logPageSize, logGroupFilter]); + + useEffect(() => { + loadGlobalSettings(); + loadGroups(); + loadChannels(); + loadConfigs(); + loadLatest(); + loadStats(); + loadTimeSeries(); + }, [loadGlobalSettings, loadGroups, loadChannels, loadConfigs, loadLatest, loadStats, loadTimeSeries]); + + useEffect(() => { + loadLogs(); + }, [loadLogs]); + + // 构建延迟趋势图 spec + const chartSpec = { + type: 'line', + data: [ + { + id: 'latencyData', + values: timeSeriesData.map((item) => ({ + time: dayjs.unix(item.created_at).format('HH:mm'), + latency: item.latency_ms, + group: item.group_name, + success: item.success, + })), + }, + ], + xField: 'time', + yField: 'latency', + seriesField: 'group', + legends: { visible: true }, + title: { + visible: true, + text: t('延迟趋势'), + subtext: t('最近1小时'), + }, + line: { + style: { + curveType: 'monotone', + lineWidth: 2, + }, + }, + point: { + visible: true, + style: { + size: 6, + fill: (datum) => (datum.success ? undefined : '#ff4d4f'), + stroke: (datum) => (datum.success ? undefined : '#ff4d4f'), + }, + }, + axes: [ + { orient: 'bottom', label: { autoRotate: true } }, + { orient: 'left', title: { visible: true, text: 'ms' } }, + ], + tooltip: { + mark: { + content: [ + { key: (datum) => datum.group, value: (datum) => `${datum.latency}ms` }, + ], + }, + }, + }; + + // 日志表格列 + const logColumns = [ + { title: t('分组'), dataIndex: 'group_name', width: 120 }, + { title: t('渠道'), dataIndex: 'channel_name', width: 150 }, + { title: t('模型'), dataIndex: 'model_name', width: 200 }, + { + title: t('延迟'), + dataIndex: 'latency_ms', + width: 100, + render: (ms) => `${ms}ms`, + }, + { + title: t('状态'), + dataIndex: 'success', + width: 80, + render: (success) => + success ? ( + {t('成功')} + ) : ( + {t('失败')} + ), + }, + { title: t('缓存模型'), dataIndex: 'cached_model', width: 200 }, + { + title: t('错误信息'), + dataIndex: 'error_msg', + width: 300, + ellipsis: true, + }, + { + title: t('时间'), + dataIndex: 'created_at', + width: 180, + render: (ts) => dayjs.unix(ts).format('YYYY-MM-DD HH:mm:ss'), + }, + ]; + + // 配置表格列 + const configColumns = [ + { title: t('分组'), dataIndex: 'group_name', width: 120 }, + { + title: t('渠道'), + dataIndex: 'channel_id', + width: 150, + render: (id) => { + const ch = channels.find((c) => c.id === id); + return ch ? `${ch.name} (#${id})` : `#${id}`; + }, + }, + { title: t('测试模型'), dataIndex: 'test_model', width: 200, render: (v) => v || t('使用全局默认') }, + { + title: t('启用'), + dataIndex: 'enabled', + width: 80, + render: (enabled) => + enabled ? ( + {t('启用')} + ) : ( + {t('禁用')} + ), + }, + { + title: t('操作'), + width: 100, + render: (_, record) => ( + deleteConfig(record.group_name)}> + + + ), + }, + ]; + + // 构建统计 map + const statsMap = {}; + statsData.forEach((s) => { + statsMap[s.group_name] = s; + }); + + return ( +
+ {t('分组监控')} + + {/* 全局设置 */} + + + + +
{t('启用分组监控')}
+ setGlobalSettings({ ...globalSettings, 'group_monitor_setting.enabled': v })} + /> + + +
{t('监控间隔')}
+ setGlobalSettings({ ...globalSettings, 'group_monitor_setting.interval_mins': parseInt(v) })} + /> + + +
{t('默认测试模型')}
+ setGlobalSettings({ ...globalSettings, 'group_monitor_setting.test_model': v })} + /> + + +
{t('日志保留天数')}
+ setGlobalSettings({ ...globalSettings, 'group_monitor_setting.retain_days': parseInt(v) })} + /> + + + + +
+
+
+ + {/* 分组渠道配置 */} + + + +
{t('分组')}
+ + + +
{t('监控渠道')}
+ + + +
{t('测试模型')}
+ setEditingConfig({ ...editingConfig, test_model: v })} + /> + + +
{t('启用')}
+ setEditingConfig({ ...editingConfig, enabled: v })} + /> + + + + +
+ + + + {/* 状态概览卡片 */} + + + {latestData.map((item) => { + const stat = statsMap[item.group_name]; + const availability = stat && stat.total_count > 0 ? ((stat.success_count / stat.total_count) * 100).toFixed(1) : '-'; + const avgLatency = stat ? Math.round(stat.avg_latency) : '-'; + return ( + + +
{item.group_name}
+
+ {t('延迟')} + + {item.latency_ms}ms + +
+
+ {t('可用率')} + = 95 ? '#52c41a' : '#ff4d4f' }}> + {availability}% + +
+
+ {t('平均延迟')} + {avgLatency}ms +
+
+ {dayjs.unix(item.created_at).format('YYYY-MM-DD HH:mm:ss')} +
+
+ + ); + })} + {latestData.length === 0 && ( + +
{t('暂无监控数据')}
+ + )} + + + + {/* 延迟趋势图 */} + {timeSeriesData.length > 0 && ( + +
+ +
+
+ )} + + {/* 监控日志 */} + +
+ + +
+
setLogPage(page), + }} + size='small' + /> + + + ); +}; + +export default GroupMonitorAdmin; diff --git a/web/src/modules/group-monitor/GroupMonitorUser.jsx b/web/src/modules/group-monitor/GroupMonitorUser.jsx new file mode 100644 index 0000000000..8d94cf968d --- /dev/null +++ b/web/src/modules/group-monitor/GroupMonitorUser.jsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, Row, Col, Tag, Typography } from '@douyinfe/semi-ui'; +import { VChart } from '@visactor/react-vchart'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { API } from '../../helpers'; + +const CHART_CONFIG = { mode: 'desktop-browser' }; + +const GroupMonitorUser = () => { + const { t } = useTranslation(); + const [statusData, setStatusData] = useState([]); + const [timeSeriesData, setTimeSeriesData] = useState([]); + + const loadStatus = useCallback(async () => { + const res = await API.get('/api/group/monitor/status'); + if (res.data.success) { + setStatusData(res.data.data || []); + } + }, []); + + const loadTimeSeries = useCallback(async () => { + const now = Math.floor(Date.now() / 1000); + const res = await API.get('/api/group/monitor/user_time_series', { + params: { start_timestamp: now - 3600 }, + }); + if (res.data.success) { + setTimeSeriesData(res.data.data || []); + } + }, []); + + useEffect(() => { + loadStatus(); + loadTimeSeries(); + // 每 60 秒自动刷新 + const interval = setInterval(() => { + loadStatus(); + loadTimeSeries(); + }, 60000); + return () => clearInterval(interval); + }, [loadStatus, loadTimeSeries]); + + const chartSpec = { + type: 'line', + data: [ + { + id: 'latencyData', + values: timeSeriesData.map((item) => ({ + time: dayjs.unix(item.created_at).format('HH:mm'), + latency: item.latency_ms, + group: item.group_name, + success: item.success, + })), + }, + ], + xField: 'time', + yField: 'latency', + seriesField: 'group', + legends: { visible: true }, + title: { + visible: true, + text: t('延迟趋势'), + subtext: t('最近1小时'), + }, + line: { + style: { + curveType: 'monotone', + lineWidth: 2, + }, + }, + point: { + visible: true, + style: { + size: 6, + fill: (datum) => (datum.success ? undefined : '#ff4d4f'), + stroke: (datum) => (datum.success ? undefined : '#ff4d4f'), + }, + }, + axes: [ + { orient: 'bottom', label: { autoRotate: true } }, + { orient: 'left', title: { visible: true, text: 'ms' } }, + ], + }; + + return ( +
+ {t('分组状态')} + + {/* 状态卡片 */} + + {statusData.map((item) => { + const availColor = item.availability >= 95 ? '#52c41a' : item.availability >= 80 ? '#faad14' : '#ff4d4f'; + return ( +
+ +
{item.group_name}
+
+ {t('状态')} + {item.latest_success ? ( + {t('正常')} + ) : ( + {t('异常')} + )} +
+
+ {t('延迟')} + {item.latest_latency}ms +
+
+ {t('可用率')} + + {item.availability > 0 ? `${item.availability.toFixed(1)}%` : '-'} + +
+
+ {t('平均延迟')} + {item.avg_latency > 0 ? `${Math.round(item.avg_latency)}ms` : '-'} +
+
+ {item.latest_time > 0 ? dayjs.unix(item.latest_time).format('YYYY-MM-DD HH:mm:ss') : '-'} +
+
+ + ); + })} + {statusData.length === 0 && ( + +
{t('暂无监控数据')}
+ + )} + + + {/* 延迟趋势图 */} + {timeSeriesData.length > 0 && ( + +
+ +
+
+ )} + + ); +}; + +export default GroupMonitorUser;