🚦 feat(uptime-kuma): support custom category names & monitor subgroup rendering
Backend • controller/uptime_kuma.go - Added Group field to Monitor struct to carry publicGroupList.name. - Extended status page parsing to capture group Name and inject it into each monitor. - Re-worked fetchGroupData loop: aggregate all sub-groups, drop unnecessary pre-allocation/breaks. Frontend • web/src/pages/Detail/index.js - renderMonitorList now buckets monitors by the new group field and renders a lightweight header per subgroup. - Fallback gracefully when group is empty to preserve previous single-list behaviour. Other • Expanded anonymous struct definition for statusData.PublicGroupList to include ID/Name, enabling JSON unmarshalling of group names. Result Custom CategoryName continues to work while each uptime group’s internal sub-groups are now clearly displayed in the UI, providing finer-grained visibility without impacting performance or existing validation logic.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
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,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
|
||||
var list []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
|
||||
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func validateURL(urlStr string, index int, itemType string) error {
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDangerousContent(content string, index int, itemType string) error {
|
||||
lower := strings.ToLower(content)
|
||||
for _, d := range dangerousChars {
|
||||
if strings.Contains(lower, d) {
|
||||
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getJSONList(jsonStr string) []map[string]interface{} {
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
json.Unmarshal([]byte(jsonStr), &list)
|
||||
return list
|
||||
}
|
||||
|
||||
func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
if settingsStr == "" {
|
||||
return nil // 空字符串是合法的
|
||||
return nil
|
||||
}
|
||||
|
||||
switch settingType {
|
||||
@@ -22,34 +70,23 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
return validateAnnouncements(settingsStr)
|
||||
case "FAQ":
|
||||
return validateFAQ(settingsStr)
|
||||
case "UptimeKumaGroups":
|
||||
return validateUptimeKumaGroups(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
}
|
||||
}
|
||||
|
||||
// validateApiInfo 验证API信息格式
|
||||
func validateApiInfo(apiInfoStr string) error {
|
||||
var apiInfoList []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
|
||||
return fmt.Errorf("API信息格式错误:%s", err.Error())
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证数组长度
|
||||
if len(apiInfoList) > 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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
for _, d := range dangerousChars {
|
||||
lower := strings.ToLower(description)
|
||||
if strings.Contains(lower, d) || strings.Contains(strings.ToLower(route), d) {
|
||||
return fmt.Errorf("第%d个API信息包含不允许的内容", i+1)
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetApiInfo 获取 API 信息列表
|
||||
func GetApiInfo() []map[string]interface{} {
|
||||
apiInfoStr := GetConsoleSetting().ApiInfo
|
||||
if apiInfoStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var apiInfo []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return apiInfo
|
||||
return getJSONList(GetConsoleSetting().ApiInfo)
|
||||
}
|
||||
|
||||
// ----------------- 公告 / FAQ -----------------
|
||||
|
||||
func validateAnnouncements(announcementsStr string) error {
|
||||
var list []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(announcementsStr), &list); err != nil {
|
||||
return fmt.Errorf("系统公告格式错误:%s", err.Error())
|
||||
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 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)
|
||||
}
|
||||
@@ -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': '',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<Empty
|
||||
image={<IllustrationConstruction />}
|
||||
darkModeImage={<IllustrationConstructionDark />}
|
||||
title={t('暂无监控数据')}
|
||||
style={{ padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
monitors.forEach((m) => {
|
||||
const g = m.group || '';
|
||||
if (!grouped[g]) grouped[g] = [];
|
||||
grouped[g].push(m);
|
||||
});
|
||||
|
||||
const renderItem = (monitor, idx) => (
|
||||
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
|
||||
<div className="flex-1">
|
||||
<Progress
|
||||
percent={(monitor.uptime || 0) * 100}
|
||||
showInfo={false}
|
||||
aria-label={`${monitor.name} uptime`}
|
||||
stroke={getUptimeStatusColor(monitor.status)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return Object.entries(grouped).map(([gname, list]) => (
|
||||
<div key={gname || 'default'} className="mb-2">
|
||||
{gname && (
|
||||
<>
|
||||
<div className="text-md font-semibold text-gray-500 px-2 py-1">
|
||||
{gname}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{list.map(renderItem)}
|
||||
</div>
|
||||
));
|
||||
}, [t, getUptimeStatusColor, getUptimeStatusText]);
|
||||
|
||||
// ========== Hooks - Effects ==========
|
||||
useEffect(() => {
|
||||
getUserData();
|
||||
@@ -1127,8 +1202,8 @@ const Detail = (props) => {
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||||
image={<IllustrationConstruction />}
|
||||
darkModeImage={<IllustrationConstructionDark />}
|
||||
title={t('暂无API信息')}
|
||||
description={t('请联系管理员在系统设置中配置API信息')}
|
||||
style={{ padding: '12px' }}
|
||||
@@ -1199,8 +1274,8 @@ const Detail = (props) => {
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||||
image={<IllustrationConstruction />}
|
||||
darkModeImage={<IllustrationConstructionDark />}
|
||||
title={t('暂无系统公告')}
|
||||
description={t('请联系管理员在系统设置中配置公告信息')}
|
||||
style={{ padding: '12px' }}
|
||||
@@ -1227,6 +1302,7 @@ const Detail = (props) => {
|
||||
{t('常见问答')}
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
@@ -1253,8 +1329,8 @@ const Detail = (props) => {
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||||
image={<IllustrationConstruction />}
|
||||
darkModeImage={<IllustrationConstructionDark />}
|
||||
title={t('暂无常见问答')}
|
||||
description={t('请联系管理员在系统设置中配置常见问答')}
|
||||
style={{ padding: '12px' }}
|
||||
@@ -1274,7 +1350,7 @@ const Detail = (props) => {
|
||||
{uptimeEnabled && (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1291,11 +1367,93 @@ const Detail = (props) => {
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
footer={uptimeData.length > 0 ? (
|
||||
<Card
|
||||
bordered={false}
|
||||
className="!rounded-2xl backdrop-blur !shadow-none"
|
||||
>
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 relative">
|
||||
<Spin spinning={uptimeLoading}>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.length === 1 ? (
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
ref={uptimeScrollRef}
|
||||
className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
|
||||
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
|
||||
>
|
||||
{renderMonitorList(uptimeData[0].monitors)}
|
||||
</div>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
type="card"
|
||||
collapsible
|
||||
activeKey={activeUptimeTab}
|
||||
onChange={setActiveUptimeTab}
|
||||
size="small"
|
||||
>
|
||||
{uptimeData.map((group, groupIdx) => {
|
||||
if (!uptimeTabScrollRefs.current[group.categoryName]) {
|
||||
uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
|
||||
}
|
||||
const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
<Gauge size={14} />
|
||||
{group.categoryName}
|
||||
<Tag
|
||||
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{group.monitors ? group.monitors.length : 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={group.categoryName}
|
||||
key={groupIdx}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<div
|
||||
ref={tabScrollRef}
|
||||
className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
|
||||
onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
|
||||
>
|
||||
{renderMonitorList(group.monitors)}
|
||||
</div>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
)
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction />}
|
||||
darkModeImage={<IllustrationConstructionDark />}
|
||||
title={t('暂无监控数据')}
|
||||
description={t('请联系管理员在系统设置中配置Uptime分组')}
|
||||
style={{ padding: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 固定在底部的图例 */}
|
||||
{uptimeData.length > 0 && (
|
||||
<div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
|
||||
<div className="flex flex-wrap gap-3 text-xs justify-center">
|
||||
{uptimeLegendData.map((legend, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
@@ -1307,63 +1465,8 @@ const Detail = (props) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
footerStyle={uptimeData.length > 0 ? { padding: '0px' } : undefined}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<Spin spinning={uptimeLoading}>
|
||||
<div
|
||||
ref={uptimeScrollRef}
|
||||
className="p-2 max-h-80 overflow-y-auto card-content-scroll"
|
||||
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
|
||||
>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.map((monitor, idx) => (
|
||||
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getUptimeStatusColor(monitor.status)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
|
||||
<div className="flex-1">
|
||||
<Progress
|
||||
percent={(monitor.uptime || 0) * 100}
|
||||
showInfo={false}
|
||||
aria-label={`${monitor.name} uptime`}
|
||||
stroke={getUptimeStatusColor(monitor.status)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||||
title={t('暂无监控数据')}
|
||||
description={t('请联系管理员在系统设置中配置Uptime')}
|
||||
style={{ padding: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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) => (
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--semi-color-text-0)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('Uptime Kuma地址'),
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--semi-color-primary)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('状态页面Slug'),
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--semi-color-text-1)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Edit size={14} />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditGroup(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteGroup(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
}, [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 = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-2">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<Activity size={16} className="mr-2" />
|
||||
<Text>
|
||||
{t('配置')}
|
||||
<a
|
||||
href="https://github.com/louislam/uptime-kuma"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Uptime Kuma
|
||||
</a>
|
||||
{t('服务监控地址,用于展示服务状态信息')}
|
||||
</Text>
|
||||
<Text>{t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('添加分类')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleSave}
|
||||
onClick={submitUptimeGroups}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="order-1 md:order-2 flex items-center gap-2">
|
||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||
</div>
|
||||
@@ -148,67 +350,132 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
autoScrollToError
|
||||
initValues={initValues}
|
||||
getFormApi={(api) => {
|
||||
formApiRef.current = api;
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData()}
|
||||
rowSelection={rowSelection}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: uptimeGroupsList.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
formatPageText: (page) => 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={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无监控数据')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Modal
|
||||
title={editingGroup ? t('编辑分类') : t('添加分类')}
|
||||
visible={showUptimeModal}
|
||||
onOk={handleSaveGroup}
|
||||
onCancel={() => setShowUptimeModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
width={600}
|
||||
>
|
||||
<Form layout='vertical' initValues={uptimeForm} key={editingGroup ? editingGroup.id : 'new'}>
|
||||
<Form.Input
|
||||
field='categoryName'
|
||||
label={t('分类名称')}
|
||||
placeholder={t('请输入分类名称,如:OpenAI、Claude等')}
|
||||
maxLength={50}
|
||||
rules={[{ required: true, message: t('请输入分类名称') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, categoryName: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('Uptime Kuma地址')}
|
||||
placeholder={t('请输入Uptime Kuma服务地址,如:https://status.example.com')}
|
||||
maxLength={500}
|
||||
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
field='slug'
|
||||
label={t('状态页面Slug')}
|
||||
placeholder={t('请输入状态页面的Slug,如:my-status')}
|
||||
maxLength={100}
|
||||
rules={[{ required: true, message: t('请输入状态页面Slug') }]}
|
||||
onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('确认删除')}
|
||||
visible={showDeleteModal}
|
||||
onOk={confirmDeleteGroup}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingGroup(null);
|
||||
}}
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
}}
|
||||
>
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Input
|
||||
showClear
|
||||
field="uptimeKumaUrl"
|
||||
label={{ text: t("Uptime Kuma 服务地址") }}
|
||||
placeholder={t("请输入 Uptime Kuma 服务地址")}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
helpText={t("请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com")}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const url = (value || '').trim();
|
||||
|
||||
if (url && !isValidUrl(url)) {
|
||||
return Promise.reject(t('请输入有效的 URL 地址'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Input
|
||||
showClear
|
||||
field="uptimeKumaSlug"
|
||||
label={{ text: t("状态页面 Slug") }}
|
||||
placeholder={t("请输入状态页面 Slug")}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
helpText={t("请输入状态页面的 slug 标识符,例如:my-status")}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const slug = (value || '').trim();
|
||||
|
||||
if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
||||
return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Form.Section>
|
||||
<Text>{t('确定要删除此分类吗?')}</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user