diff --git a/controller/option.go b/controller/option.go index 521d7327..79ba2ffe 100644 --- a/controller/option.go +++ b/controller/option.go @@ -147,6 +147,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "console_setting.uptime_kuma_groups": + err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } } err = model.UpdateOption(option.Key, option.Value) if err != nil { diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go index e89b2b9d..05d6297e 100644 --- a/controller/uptime_kuma.go +++ b/controller/uptime_kuma.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "one-api/setting/console_setting" + "strconv" "strings" "time" @@ -14,45 +14,25 @@ import ( "golang.org/x/sync/errgroup" ) -type UptimeKumaMonitor struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` -} +const ( + requestTimeout = 30 * time.Second + httpTimeout = 10 * time.Second + uptimeKeySuffix = "_24" + apiStatusPath = "/api/status-page/" + apiHeartbeatPath = "/api/status-page/heartbeat/" +) -type UptimeKumaGroup struct { - ID int `json:"id"` - Name string `json:"name"` - Weight int `json:"weight"` - MonitorList []UptimeKumaMonitor `json:"monitorList"` -} - -type UptimeKumaHeartbeat struct { - Status int `json:"status"` - Time string `json:"time"` - Msg string `json:"msg"` - Ping *float64 `json:"ping"` -} - -type UptimeKumaStatusResponse struct { - PublicGroupList []UptimeKumaGroup `json:"publicGroupList"` -} - -type UptimeKumaHeartbeatResponse struct { - HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"` - UptimeList map[string]float64 `json:"uptimeList"` -} - -type MonitorStatus struct { +type Monitor struct { Name string `json:"name"` Uptime float64 `json:"uptime"` Status int `json:"status"` + Group string `json:"group,omitempty"` } -var ( - ErrUpstreamNon200 = errors.New("upstream non-200") - ErrTimeout = errors.New("context deadline exceeded") -) +type UptimeGroupResult struct { + CategoryName string `json:"categoryName"` + Monitors []Monitor `json:"monitors"` +} func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -62,107 +42,113 @@ func getAndDecode(ctx context.Context, client *http.Client, url string, dest int resp, err := client.Do(req) if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return ErrTimeout - } return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return ErrUpstreamNon200 + return errors.New("non-200 status") } return json.NewDecoder(resp.Body).Decode(dest) } -func GetUptimeKumaStatus(c *gin.Context) { - cs := console_setting.GetConsoleSetting() - uptimeKumaUrl := cs.UptimeKumaUrl - slug := cs.UptimeKumaSlug - - if uptimeKumaUrl == "" || slug == "" { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": []MonitorStatus{}, - }) - return +func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult { + url, _ := groupConfig["url"].(string) + slug, _ := groupConfig["slug"].(string) + categoryName, _ := groupConfig["categoryName"].(string) + + result := UptimeGroupResult{ + CategoryName: categoryName, + Monitors: []Monitor{}, + } + + if url == "" || slug == "" { + return result } - uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/") - - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() - - client := &http.Client{} - - statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug) - heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug) - - var ( - statusData UptimeKumaStatusResponse - heartbeatData UptimeKumaHeartbeatResponse - ) + baseURL := strings.TrimSuffix(url, "/") + + var statusData struct { + PublicGroupList []struct { + ID int `json:"id"` + Name string `json:"name"` + MonitorList []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"monitorList"` + } `json:"publicGroupList"` + } + + var heartbeatData struct { + HeartbeatList map[string][]struct { + Status int `json:"status"` + } `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` + } g, gCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - return getAndDecode(gCtx, client, statusPageUrl, &statusData) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) + }) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) }) - g.Go(func() error { - return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData) - }) + if g.Wait() != nil { + return result + } - if err := g.Wait(); err != nil { - switch err { - case ErrUpstreamNon200: - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": "上游接口出现问题", - }) - case ErrTimeout: - c.JSON(http.StatusRequestTimeout, gin.H{ - "success": false, - "message": "请求上游接口超时", - }) - default: - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": err.Error(), - }) + for _, pg := range statusData.PublicGroupList { + if len(pg.MonitorList) == 0 { + continue } + + for _, m := range pg.MonitorList { + monitor := Monitor{ + Name: m.Name, + Group: pg.Name, + } + + monitorID := strconv.Itoa(m.ID) + + if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists { + monitor.Uptime = uptime + } + + if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 { + monitor.Status = heartbeats[0].Status + } + + result.Monitors = append(result.Monitors, monitor) + } + } + + return result +} + +func GetUptimeKumaStatus(c *gin.Context) { + groups := console_setting.GetUptimeKumaGroups() + if len(groups) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}}) return } - var monitors []MonitorStatus - for _, group := range statusData.PublicGroupList { - for _, monitor := range group.MonitorList { - monitorStatus := MonitorStatus{ - Name: monitor.Name, - Uptime: 0.0, - Status: 0, - } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() - uptimeKey := fmt.Sprintf("%d_24", monitor.ID) - if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists { - monitorStatus.Uptime = uptime - } - - heartbeatKey := fmt.Sprintf("%d", monitor.ID) - if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 { - latestHeartbeat := heartbeats[0] - monitorStatus.Status = latestHeartbeat.Status - } - - monitors = append(monitors, monitorStatus) - } + client := &http.Client{Timeout: httpTimeout} + results := make([]UptimeGroupResult, len(groups)) + + g, gCtx := errgroup.WithContext(ctx) + for i, group := range groups { + i, group := i, group + g.Go(func() error { + results[i] = fetchGroupData(gCtx, client, group) + return nil + }) } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": monitors, - }) + + g.Wait() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) } \ No newline at end of file diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go index 063130fc..6327e558 100644 --- a/setting/console_setting/config.go +++ b/setting/console_setting/config.go @@ -3,11 +3,10 @@ package console_setting import "one-api/setting/config" type ConsoleSetting struct { - ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) - UptimeKumaUrl string `json:"uptime_kuma_url"` // Uptime Kuma 服务地址(如 https://status.example.com ) - UptimeKumaSlug string `json:"uptime_kuma_slug"` // Uptime Kuma Status Page Slug - Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) - FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 @@ -16,11 +15,10 @@ type ConsoleSetting struct { // 默认配置 var defaultConsoleSetting = ConsoleSetting{ - ApiInfo: "", - UptimeKumaUrl: "", - UptimeKumaSlug: "", - Announcements: "", - FAQ: "", + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", ApiInfoEnabled: true, UptimeKumaEnabled: true, AnnouncementsEnabled: true, diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index 3a9f3c83..51a84849 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -9,10 +9,58 @@ import ( "time" ) -// ValidateConsoleSettings 验证控制台设置信息格式 +var ( + urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) + dangerousChars = []string{" 50 { return fmt.Errorf("API信息数量不能超过50个") } - // 允许的颜色值 - validColors := map[string]bool{ - "blue": true, "green": true, "cyan": true, "purple": true, "pink": true, - "red": true, "orange": true, "amber": true, "yellow": true, "lime": true, - "light-green": true, "teal": true, "light-blue": true, "indigo": true, - "violet": true, "grey": true, - } - - // URL 正则,支持域名 / IP - urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) - for i, apiInfo := range apiInfoList { urlStr, ok := apiInfo["url"].(string) if !ok || urlStr == "" { @@ -67,12 +104,11 @@ func validateApiInfo(apiInfoStr string) error { if !ok || color == "" { return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) } - if !urlRegex.MatchString(urlStr) { - return fmt.Errorf("第%d个API信息的URL格式不正确", i+1) - } - if _, err := url.Parse(urlStr); err != nil { - return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error()) + + if err := validateURL(urlStr, i+1, "API信息"); err != nil { + return err } + if len(urlStr) > 500 { return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) } @@ -82,39 +118,29 @@ func validateApiInfo(apiInfoStr string) error { if len(description) > 200 { return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) } + if !validColors[color] { return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) } - dangerousChars := []string{" 100 { return fmt.Errorf("系统公告数量不能超过100个") @@ -158,9 +184,9 @@ func validateAnnouncements(announcementsStr string) error { } func validateFAQ(faqStr string) error { - var list []map[string]interface{} - if err := json.Unmarshal([]byte(faqStr), &list); err != nil { - return fmt.Errorf("FAQ信息格式错误:%s", err.Error()) + list, err := parseJSONArray(faqStr, "FAQ信息") + if err != nil { + return err } if len(list) > 100 { return fmt.Errorf("FAQ数量不能超过100个") @@ -184,24 +210,79 @@ func validateFAQ(faqStr string) error { return nil } -// GetAnnouncements 获取系统公告 func GetAnnouncements() []map[string]interface{} { - annStr := GetConsoleSetting().Announcements - if annStr == "" { - return []map[string]interface{}{} - } - var ann []map[string]interface{} - _ = json.Unmarshal([]byte(annStr), &ann) - return ann + return getJSONList(GetConsoleSetting().Announcements) } -// GetFAQ 获取常见问题 func GetFAQ() []map[string]interface{} { - faqStr := GetConsoleSetting().FAQ - if faqStr == "" { - return []map[string]interface{}{} + return getJSONList(GetConsoleSetting().FAQ) +} + +func validateUptimeKumaGroups(groupsStr string) error { + groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") + if err != nil { + return err } - var faq []map[string]interface{} - _ = json.Unmarshal([]byte(faqStr), &faq) - return faq + + if len(groups) > 20 { + return fmt.Errorf("Uptime Kuma分组数量不能超过20个") + } + + nameSet := make(map[string]bool) + + for i, group := range groups { + categoryName, ok := group["categoryName"].(string) + if !ok || categoryName == "" { + return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) + } + if nameSet[categoryName] { + return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) + } + nameSet[categoryName] = true + urlStr, ok := group["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个分组缺少URL字段", i+1) + } + slug, ok := group["slug"].(string) + if !ok || slug == "" { + return fmt.Errorf("第%d个分组缺少Slug字段", i+1) + } + description, ok := group["description"].(string) + if !ok { + description = "" + } + + if err := validateURL(urlStr, i+1, "分组"); err != nil { + return err + } + + if len(categoryName) > 50 { + return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) + } + if len(slug) > 100 { + return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) + } + + if !slugRegex.MatchString(slug) { + return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) + } + + if err := checkDangerousContent(description, i+1, "分组"); err != nil { + return err + } + if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { + return err + } + } + return nil +} + +func GetUptimeKumaGroups() []map[string]interface{} { + return getJSONList(GetConsoleSetting().UptimeKumaGroups) } \ No newline at end of file diff --git a/web/src/components/settings/DashboardSetting.js b/web/src/components/settings/DashboardSetting.js index 0546ca21..bf4a26a3 100644 --- a/web/src/components/settings/DashboardSetting.js +++ b/web/src/components/settings/DashboardSetting.js @@ -11,8 +11,7 @@ const DashboardSetting = () => { 'console_setting.api_info': '', 'console_setting.announcements': '', 'console_setting.faq': '', - 'console_setting.uptime_kuma_url': '', - 'console_setting.uptime_kuma_slug': '', + 'console_setting.uptime_kuma_groups': '', 'console_setting.api_info_enabled': '', 'console_setting.announcements_enabled': '', 'console_setting.faq_enabled': '', diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3b523cbf..11bea1e4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1628,16 +1628,15 @@ "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)", "暂无常见问答": "No FAQ", "显示最新20条": "Display latest 20", - "Uptime Kuma 服务地址": "Uptime Kuma service address", - "状态页面 Slug": "Status page slug", - "请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com", - "请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status", - "Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty", - "请输入有效的 URL 地址": "Please enter a valid URL address", - "状态页面 Slug 不能为空": "Status page slug cannot be empty", - "Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens", - "请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address", - "请输入状态页面 Slug": "Please enter the status page slug", + "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma monitoring category management, you can configure multiple monitoring categories for service status display (maximum 20)", + "添加分类": "Add Category", + "分类名称": "Category Name", + "Uptime Kuma地址": "Uptime Kuma Address", + "状态页面Slug": "Status Page Slug", + "请输入分类名称,如:OpenAI、Claude等": "Please enter the category name, such as: OpenAI, Claude, etc.", + "请输入Uptime Kuma服务地址,如:https://status.example.com": "Please enter the Uptime Kuma service address, such as: https://status.example.com", + "请输入状态页面的Slug,如:my-status": "Please enter the slug for the status page, such as: my-status", + "确定要删除此分类吗?": "Are you sure you want to delete this category?", "配置": "Configure", "服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information", "服务可用性": "Service Status", diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 747c1cf6..777924e0 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -16,7 +16,8 @@ import { Tag, Timeline, Collapse, - Progress + Progress, + Divider } from '@douyinfe/semi-ui'; import { IconRefresh, @@ -215,6 +216,7 @@ const Detail = (props) => { const announcementScrollRef = useRef(null); const faqScrollRef = useRef(null); const uptimeScrollRef = useRef(null); + const uptimeTabScrollRefs = useRef({}); // ========== Additional State for scroll hints ========== const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false); @@ -224,6 +226,7 @@ const Detail = (props) => { // ========== Uptime data ========== const [uptimeData, setUptimeData] = useState([]); const [uptimeLoading, setUptimeLoading] = useState(false); + const [activeUptimeTab, setActiveUptimeTab] = useState(''); // ========== Props Destructuring ========== const { username, model_name, start_timestamp, end_timestamp, channel } = inputs; @@ -579,6 +582,9 @@ const Detail = (props) => { const { success, message, data } = res.data; if (success) { setUptimeData(data || []); + if (data && data.length > 0 && !activeUptimeTab) { + setActiveUptimeTab(data[0].categoryName); + } } else { showError(message); } @@ -587,7 +593,7 @@ const Detail = (props) => { } finally { setUptimeLoading(false); } - }, []); + }, [activeUptimeTab]); const refresh = useCallback(async () => { await Promise.all([loadQuotaData(), loadUptimeData()]); @@ -644,10 +650,18 @@ const Detail = (props) => { checkApiScrollable(); checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint); checkCardScrollable(faqScrollRef, setShowFaqScrollHint); - checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint); + + if (uptimeData.length === 1) { + checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint); + } else if (uptimeData.length > 1 && activeUptimeTab) { + const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab]; + if (activeTabRef) { + checkCardScrollable(activeTabRef, setShowUptimeScrollHint); + } + } }, 100); return () => clearTimeout(timer); - }, [uptimeData]); + }, [uptimeData, activeUptimeTab]); const getUserData = async () => { let res = await API.get(`/api/user/self`); @@ -883,7 +897,6 @@ const Detail = (props) => { const announcementData = useMemo(() => { const announcements = statusState?.status?.announcements || []; - // 处理后台配置的公告数据,自动生成相对时间 return announcements.map(item => ({ ...item, time: getRelativeTime(item.publishDate) @@ -894,6 +907,68 @@ const Detail = (props) => { return statusState?.status?.faq || []; }, [statusState?.status?.faq]); + const renderMonitorList = useCallback((monitors) => { + if (!monitors || monitors.length === 0) { + return ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + style={{ padding: '8px' }} + /> +
+ ); + } + + const grouped = {}; + monitors.forEach((m) => { + const g = m.group || ''; + if (!grouped[g]) grouped[g] = []; + grouped[g].push(m); + }); + + const renderItem = (monitor, idx) => ( +
+
+
+
+ {monitor.name} +
+ {((monitor.uptime || 0) * 100).toFixed(2)}% +
+
+ {getUptimeStatusText(monitor.status)} +
+ +
+
+
+ ); + + return Object.entries(grouped).map(([gname, list]) => ( +
+ {gname && ( + <> +
+ {gname} +
+ + + )} + {list.map(renderItem)} +
+ )); + }, [t, getUptimeStatusColor, getUptimeStatusText]); + // ========== Hooks - Effects ========== useEffect(() => { getUserData(); @@ -1127,8 +1202,8 @@ const Detail = (props) => { ) : (
} - darkModeImage={} + image={} + darkModeImage={} title={t('暂无API信息')} description={t('请联系管理员在系统设置中配置API信息')} style={{ padding: '12px' }} @@ -1199,8 +1274,8 @@ const Detail = (props) => { ) : (
} - darkModeImage={} + image={} + darkModeImage={} title={t('暂无系统公告')} description={t('请联系管理员在系统设置中配置公告信息')} style={{ padding: '12px' }} @@ -1227,6 +1302,7 @@ const Detail = (props) => { {t('常见问答')}
} + bodyStyle={{ padding: 0 }} >
{ ) : (
} - darkModeImage={} + image={} + darkModeImage={} title={t('暂无常见问答')} description={t('请联系管理员在系统设置中配置常见问答')} style={{ padding: '12px' }} @@ -1274,7 +1350,7 @@ const Detail = (props) => { {uptimeEnabled && (
@@ -1291,11 +1367,93 @@ const Detail = (props) => { />
} - footer={uptimeData.length > 0 ? ( - + bodyStyle={{ padding: 0 }} + > + {/* 内容区域 */} +
+ + {uptimeData.length > 0 ? ( + uptimeData.length === 1 ? ( +
+
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} + > + {renderMonitorList(uptimeData[0].monitors)} +
+
+
+ ) : ( + + {uptimeData.map((group, groupIdx) => { + if (!uptimeTabScrollRefs.current[group.categoryName]) { + uptimeTabScrollRefs.current[group.categoryName] = React.createRef(); + } + const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName]; + + return ( + + + {group.categoryName} + + {group.monitors ? group.monitors.length : 0} + + + } + itemKey={group.categoryName} + key={groupIdx} + > +
+
handleCardScroll(tabScrollRef, setShowUptimeScrollHint)} + > + {renderMonitorList(group.monitors)} +
+
+
+ + ); + })} + + ) + ) : ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + description={t('请联系管理员在系统设置中配置Uptime分组')} + style={{ padding: '12px' }} + /> +
+ )} + +
+ + {/* 固定在底部的图例 */} + {uptimeData.length > 0 && ( +
{uptimeLegendData.map((legend, index) => (
@@ -1307,63 +1465,8 @@ const Detail = (props) => {
))}
- - ) : null} - footerStyle={uptimeData.length > 0 ? { padding: '0px' } : undefined} - > -
- -
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} - > - {uptimeData.length > 0 ? ( - uptimeData.map((monitor, idx) => ( -
-
-
-
- {monitor.name} -
- {((monitor.uptime || 0) * 100).toFixed(2)}% -
-
- {getUptimeStatusText(monitor.status)} -
- -
-
-
- )) - ) : ( -
- } - darkModeImage={} - title={t('暂无监控数据')} - description={t('请联系管理员在系统设置中配置Uptime')} - style={{ padding: '12px' }} - /> -
- )} -
- -
-
+
+ )} )}
diff --git a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js index d489b683..0b1a2749 100644 --- a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js +++ b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js @@ -1,13 +1,23 @@ -import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import { - Form, Button, + Space, + Table, + Form, Typography, - Row, - Col, - Switch, + Empty, + Divider, + Modal, + Switch } from '@douyinfe/semi-ui'; import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { + Plus, + Edit, + Trash2, Save, Activity } from 'lucide-react'; @@ -19,69 +29,242 @@ const { Text } = Typography; const SettingsUptimeKuma = ({ options, refresh }) => { const { t } = useTranslation(); + const [uptimeGroupsList, setUptimeGroupsList] = useState([]); + const [showUptimeModal, setShowUptimeModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(null); + const [editingGroup, setEditingGroup] = useState(null); + const [modalLoading, setModalLoading] = useState(false); const [loading, setLoading] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [uptimeForm, setUptimeForm] = useState({ + categoryName: '', + url: '', + slug: '', + }); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [panelEnabled, setPanelEnabled] = useState(true); - const formApiRef = useRef(null); - const initValues = useMemo(() => ({ - uptimeKumaUrl: options?.['console_setting.uptime_kuma_url'] || '', - uptimeKumaSlug: options?.['console_setting.uptime_kuma_slug'] || '' - }), [options?.['console_setting.uptime_kuma_url'], options?.['console_setting.uptime_kuma_slug']]); - - useEffect(() => { - if (formApiRef.current) { - formApiRef.current.setValues(initValues, { isOverride: true }); + const columns = [ + { + title: t('分类名称'), + dataIndex: 'categoryName', + key: 'categoryName', + render: (text) => ( +
+ {text} +
+ ) + }, + { + title: t('Uptime Kuma地址'), + dataIndex: 'url', + key: 'url', + render: (text) => ( +
+ {text} +
+ ) + }, + { + title: t('状态页面Slug'), + dataIndex: 'slug', + key: 'slug', + render: (text) => ( +
+ {text} +
+ ) + }, + { + title: t('操作'), + key: 'action', + fixed: 'right', + width: 150, + render: (text, record) => ( + + + + + ) } - }, [initValues]); + ]; - useEffect(() => { - const enabledStr = options?.['console_setting.uptime_kuma_enabled']; - setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true); - }, [options?.['console_setting.uptime_kuma_enabled']]); - - const handleSave = async () => { - const api = formApiRef.current; - if (!api) { - showError(t('表单未初始化')); - return; + const updateOption = async (key, value) => { + const res = await API.put('/api/option/', { + key, + value, + }); + const { success, message } = res.data; + if (success) { + showSuccess('Uptime Kuma配置已更新'); + if (refresh) refresh(); + } else { + showError(message); } + }; + const submitUptimeGroups = async () => { try { setLoading(true); - const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate(); - - const trimmedUrl = (uptimeKumaUrl || '').trim(); - const trimmedSlug = (uptimeKumaSlug || '').trim(); - - if (trimmedUrl === options?.['console_setting.uptime_kuma_url'] && trimmedSlug === options?.['console_setting.uptime_kuma_slug']) { - showSuccess(t('无需保存,配置未变动')); - return; - } - - const [urlRes, slugRes] = await Promise.all([ - trimmedUrl === options?.['console_setting.uptime_kuma_url'] ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', { - key: 'console_setting.uptime_kuma_url', - value: trimmedUrl - }), - trimmedSlug === options?.['console_setting.uptime_kuma_slug'] ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', { - key: 'console_setting.uptime_kuma_slug', - value: trimmedSlug - }) - ]); - - if (!urlRes.data.success) throw new Error(urlRes.data.message || t('URL 保存失败')); - if (!slugRes.data.success) throw new Error(slugRes.data.message || t('Slug 保存失败')); - - showSuccess(t('Uptime Kuma 设置保存成功')); - refresh?.(); - } catch (err) { - console.error(err); - showError(err.message || t('保存失败,请重试')); + const groupsJson = JSON.stringify(uptimeGroupsList); + await updateOption('console_setting.uptime_kuma_groups', groupsJson); + setHasChanges(false); + } catch (error) { + console.error('Uptime Kuma配置更新失败', error); + showError('Uptime Kuma配置更新失败'); } finally { setLoading(false); } }; + const handleAddGroup = () => { + setEditingGroup(null); + setUptimeForm({ + categoryName: '', + url: '', + slug: '', + }); + setShowUptimeModal(true); + }; + + const handleEditGroup = (group) => { + setEditingGroup(group); + setUptimeForm({ + categoryName: group.categoryName, + url: group.url, + slug: group.slug, + }); + setShowUptimeModal(true); + }; + + const handleDeleteGroup = (group) => { + setDeletingGroup(group); + setShowDeleteModal(true); + }; + + const confirmDeleteGroup = () => { + if (deletingGroup) { + const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id); + setUptimeGroupsList(newList); + setHasChanges(true); + showSuccess('分类已删除,请及时点击“保存设置”进行保存'); + } + setShowDeleteModal(false); + setDeletingGroup(null); + }; + + const handleSaveGroup = async () => { + if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) { + showError('请填写完整的分类信息'); + return; + } + + try { + new URL(uptimeForm.url); + } catch (error) { + showError('请输入有效的URL地址'); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) { + showError('Slug只能包含字母、数字、下划线和连字符'); + return; + } + + try { + setModalLoading(true); + + let newList; + if (editingGroup) { + newList = uptimeGroupsList.map(item => + item.id === editingGroup.id + ? { ...item, ...uptimeForm } + : item + ); + } else { + const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1; + const newGroup = { + id: newId, + ...uptimeForm + }; + newList = [...uptimeGroupsList, newGroup]; + } + + setUptimeGroupsList(newList); + setHasChanges(true); + setShowUptimeModal(false); + showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存'); + } catch (error) { + showError('操作失败: ' + error.message); + } finally { + setModalLoading(false); + } + }; + + const parseUptimeGroups = (groupsStr) => { + if (!groupsStr) { + setUptimeGroupsList([]); + return; + } + + try { + const parsed = JSON.parse(groupsStr); + const list = Array.isArray(parsed) ? parsed : []; + const listWithIds = list.map((item, index) => ({ + ...item, + id: item.id || index + 1 + })); + setUptimeGroupsList(listWithIds); + } catch (error) { + console.error('解析Uptime Kuma配置失败:', error); + setUptimeGroupsList([]); + } + }; + + useEffect(() => { + const groupsStr = options['console_setting.uptime_kuma_groups']; + if (groupsStr !== undefined) { + parseUptimeGroups(groupsStr); + } + }, [options['console_setting.uptime_kuma_groups']]); + + useEffect(() => { + const enabledStr = options['console_setting.uptime_kuma_enabled']; + setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true); + }, [options['console_setting.uptime_kuma_enabled']]); + const handleToggleEnabled = async (checked) => { const newValue = checked ? 'true' : 'false'; try { @@ -101,46 +284,65 @@ const SettingsUptimeKuma = ({ options, refresh }) => { } }; - const isValidUrl = useCallback((string) => { - try { - new URL(string); - return true; - } catch (_) { - return false; + const handleBatchDelete = () => { + if (selectedRowKeys.length === 0) { + showError('请先选择要删除的分类'); + return; } - }, []); + + const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id)); + setUptimeGroupsList(newList); + setSelectedRowKeys([]); + setHasChanges(true); + showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`); + }; const renderHeader = () => (
-
+
- - {t('配置')}  - - Uptime Kuma - -  {t('服务监控地址,用于展示服务状态信息')} - + {t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}
+
-
+ + +
+
+ + +
+ {/* 启用开关 */} +
{panelEnabled ? t('已启用') : t('已禁用')}
@@ -148,67 +350,132 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
); + const getCurrentPageData = () => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return uptimeGroupsList.slice(startIndex, endIndex); + }; + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys, selectedRows) => { + setSelectedRowKeys(selectedRowKeys); + }, + onSelect: (record, selected, selectedRows) => { + console.log(`选择行: ${selected}`, record); + }, + onSelectAll: (selected, selectedRows) => { + console.log(`全选: ${selected}`, selectedRows); + }, + getCheckboxProps: (record) => ({ + disabled: false, + name: record.id, + }), + }; + return ( - -
{ - formApiRef.current = api; + <> + + t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: uptimeGroupsList.length, + }), + pageSizeOptions: ['5', '10', '20', '50'], + onChange: (page, size) => { + setCurrentPage(page); + setPageSize(size); + }, + onShowSizeChange: (current, size) => { + setCurrentPage(1); + setPageSize(size); + } + }} + size='middle' + loading={loading} + empty={ + } + darkModeImage={} + description={t('暂无监控数据')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + /> + + + setShowUptimeModal(false)} + okText={t('保存')} + cancelText={t('取消')} + className="rounded-xl" + confirmLoading={modalLoading} + width={600} + > + + setUptimeForm({ ...uptimeForm, categoryName: value })} + /> + setUptimeForm({ ...uptimeForm, url: value })} + /> + setUptimeForm({ ...uptimeForm, slug: value })} + /> + + + + { + setShowDeleteModal(false); + setDeletingGroup(null); + }} + okText={t('确认删除')} + cancelText={t('取消')} + type="warning" + className="rounded-xl" + okButtonProps={{ + type: 'danger', + theme: 'solid' }} > - - - { - const url = (value || '').trim(); - - if (url && !isValidUrl(url)) { - return Promise.reject(t('请输入有效的 URL 地址')); - } - - return Promise.resolve(); - } - } - ]} - /> - - - - { - const slug = (value || '').trim(); - - if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) { - return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符')); - } - - return Promise.resolve(); - } - } - ]} - /> - - - - + {t('确定要删除此分类吗?')} + + ); };