🚦 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
|
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)
|
err = model.UpdateOption(option.Key, option.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/setting/console_setting"
|
"one-api/setting/console_setting"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,45 +14,25 @@ import (
|
|||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UptimeKumaMonitor struct {
|
const (
|
||||||
ID int `json:"id"`
|
requestTimeout = 30 * time.Second
|
||||||
Name string `json:"name"`
|
httpTimeout = 10 * time.Second
|
||||||
Type string `json:"type"`
|
uptimeKeySuffix = "_24"
|
||||||
}
|
apiStatusPath = "/api/status-page/"
|
||||||
|
apiHeartbeatPath = "/api/status-page/heartbeat/"
|
||||||
|
)
|
||||||
|
|
||||||
type UptimeKumaGroup struct {
|
type Monitor 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 {
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
type UptimeGroupResult struct {
|
||||||
ErrUpstreamNon200 = errors.New("upstream non-200")
|
CategoryName string `json:"categoryName"`
|
||||||
ErrTimeout = errors.New("context deadline exceeded")
|
Monitors []Monitor `json:"monitors"`
|
||||||
)
|
}
|
||||||
|
|
||||||
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
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)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
||||||
return ErrTimeout
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return ErrUpstreamNon200
|
return errors.New("non-200 status")
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.NewDecoder(resp.Body).Decode(dest)
|
return json.NewDecoder(resp.Body).Decode(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUptimeKumaStatus(c *gin.Context) {
|
func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult {
|
||||||
cs := console_setting.GetConsoleSetting()
|
url, _ := groupConfig["url"].(string)
|
||||||
uptimeKumaUrl := cs.UptimeKumaUrl
|
slug, _ := groupConfig["slug"].(string)
|
||||||
slug := cs.UptimeKumaSlug
|
categoryName, _ := groupConfig["categoryName"].(string)
|
||||||
|
|
||||||
if uptimeKumaUrl == "" || slug == "" {
|
result := UptimeGroupResult{
|
||||||
c.JSON(http.StatusOK, gin.H{
|
CategoryName: categoryName,
|
||||||
"success": true,
|
Monitors: []Monitor{},
|
||||||
"message": "",
|
}
|
||||||
"data": []MonitorStatus{},
|
|
||||||
})
|
if url == "" || slug == "" {
|
||||||
return
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
|
baseURL := strings.TrimSuffix(url, "/")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
var statusData struct {
|
||||||
defer cancel()
|
PublicGroupList []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
client := &http.Client{}
|
Name string `json:"name"`
|
||||||
|
MonitorList []struct {
|
||||||
statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
|
ID int `json:"id"`
|
||||||
heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
|
Name string `json:"name"`
|
||||||
|
} `json:"monitorList"`
|
||||||
var (
|
} `json:"publicGroupList"`
|
||||||
statusData UptimeKumaStatusResponse
|
}
|
||||||
heartbeatData UptimeKumaHeartbeatResponse
|
|
||||||
)
|
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, gCtx := errgroup.WithContext(ctx)
|
||||||
|
g.Go(func() error {
|
||||||
g.Go(func() error {
|
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
|
||||||
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
|
})
|
||||||
|
g.Go(func() error {
|
||||||
|
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
|
||||||
})
|
})
|
||||||
|
|
||||||
g.Go(func() error {
|
if g.Wait() != nil {
|
||||||
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
|
return result
|
||||||
})
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
for _, pg := range statusData.PublicGroupList {
|
||||||
switch err {
|
if len(pg.MonitorList) == 0 {
|
||||||
case ErrUpstreamNon200:
|
continue
|
||||||
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 _, 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var monitors []MonitorStatus
|
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
|
||||||
for _, group := range statusData.PublicGroupList {
|
defer cancel()
|
||||||
for _, monitor := range group.MonitorList {
|
|
||||||
monitorStatus := MonitorStatus{
|
|
||||||
Name: monitor.Name,
|
|
||||||
Uptime: 0.0,
|
|
||||||
Status: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
|
client := &http.Client{Timeout: httpTimeout}
|
||||||
if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
|
results := make([]UptimeGroupResult, len(groups))
|
||||||
monitorStatus.Uptime = uptime
|
|
||||||
}
|
g, gCtx := errgroup.WithContext(ctx)
|
||||||
|
for i, group := range groups {
|
||||||
heartbeatKey := fmt.Sprintf("%d", monitor.ID)
|
i, group := i, group
|
||||||
if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
|
g.Go(func() error {
|
||||||
latestHeartbeat := heartbeats[0]
|
results[i] = fetchGroupData(gCtx, client, group)
|
||||||
monitorStatus.Status = latestHeartbeat.Status
|
return nil
|
||||||
}
|
})
|
||||||
|
|
||||||
monitors = append(monitors, monitorStatus)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
g.Wait()
|
||||||
"success": true,
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
|
||||||
"message": "",
|
|
||||||
"data": monitors,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,10 @@ package console_setting
|
|||||||
import "one-api/setting/config"
|
import "one-api/setting/config"
|
||||||
|
|
||||||
type ConsoleSetting struct {
|
type ConsoleSetting struct {
|
||||||
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
|
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
|
||||||
UptimeKumaUrl string `json:"uptime_kuma_url"` // Uptime Kuma 服务地址(如 https://status.example.com )
|
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
|
||||||
UptimeKumaSlug string `json:"uptime_kuma_slug"` // Uptime Kuma Status Page Slug
|
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
|
||||||
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
|
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
|
||||||
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
|
|
||||||
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
|
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
|
||||||
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
|
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
|
||||||
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
|
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
|
||||||
@@ -16,11 +15,10 @@ type ConsoleSetting struct {
|
|||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
var defaultConsoleSetting = ConsoleSetting{
|
var defaultConsoleSetting = ConsoleSetting{
|
||||||
ApiInfo: "",
|
ApiInfo: "",
|
||||||
UptimeKumaUrl: "",
|
UptimeKumaGroups: "",
|
||||||
UptimeKumaSlug: "",
|
Announcements: "",
|
||||||
Announcements: "",
|
FAQ: "",
|
||||||
FAQ: "",
|
|
||||||
ApiInfoEnabled: true,
|
ApiInfoEnabled: true,
|
||||||
UptimeKumaEnabled: true,
|
UptimeKumaEnabled: true,
|
||||||
AnnouncementsEnabled: true,
|
AnnouncementsEnabled: true,
|
||||||
|
|||||||
@@ -9,10 +9,58 @@ import (
|
|||||||
"time"
|
"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 {
|
func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||||
if settingsStr == "" {
|
if settingsStr == "" {
|
||||||
return nil // 空字符串是合法的
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch settingType {
|
switch settingType {
|
||||||
@@ -22,34 +70,23 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
|||||||
return validateAnnouncements(settingsStr)
|
return validateAnnouncements(settingsStr)
|
||||||
case "FAQ":
|
case "FAQ":
|
||||||
return validateFAQ(settingsStr)
|
return validateFAQ(settingsStr)
|
||||||
|
case "UptimeKumaGroups":
|
||||||
|
return validateUptimeKumaGroups(settingsStr)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateApiInfo 验证API信息格式
|
|
||||||
func validateApiInfo(apiInfoStr string) error {
|
func validateApiInfo(apiInfoStr string) error {
|
||||||
var apiInfoList []map[string]interface{}
|
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||||
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("API信息格式错误:%s", err.Error())
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证数组长度
|
|
||||||
if len(apiInfoList) > 50 {
|
if len(apiInfoList) > 50 {
|
||||||
return fmt.Errorf("API信息数量不能超过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 {
|
for i, apiInfo := range apiInfoList {
|
||||||
urlStr, ok := apiInfo["url"].(string)
|
urlStr, ok := apiInfo["url"].(string)
|
||||||
if !ok || urlStr == "" {
|
if !ok || urlStr == "" {
|
||||||
@@ -67,12 +104,11 @@ func validateApiInfo(apiInfoStr string) error {
|
|||||||
if !ok || color == "" {
|
if !ok || color == "" {
|
||||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||||
}
|
}
|
||||||
if !urlRegex.MatchString(urlStr) {
|
|
||||||
return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
|
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
|
||||||
}
|
return err
|
||||||
if _, err := url.Parse(urlStr); err != nil {
|
|
||||||
return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(urlStr) > 500 {
|
if len(urlStr) > 500 {
|
||||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||||
}
|
}
|
||||||
@@ -82,39 +118,29 @@ func validateApiInfo(apiInfoStr string) error {
|
|||||||
if len(description) > 200 {
|
if len(description) > 200 {
|
||||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validColors[color] {
|
if !validColors[color] {
|
||||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||||
}
|
}
|
||||||
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
|
||||||
for _, d := range dangerousChars {
|
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||||
lower := strings.ToLower(description)
|
return err
|
||||||
if strings.Contains(lower, d) || strings.Contains(strings.ToLower(route), d) {
|
}
|
||||||
return fmt.Errorf("第%d个API信息包含不允许的内容", i+1)
|
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetApiInfo 获取 API 信息列表
|
|
||||||
func GetApiInfo() []map[string]interface{} {
|
func GetApiInfo() []map[string]interface{} {
|
||||||
apiInfoStr := GetConsoleSetting().ApiInfo
|
return getJSONList(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------- 公告 / FAQ -----------------
|
|
||||||
|
|
||||||
func validateAnnouncements(announcementsStr string) error {
|
func validateAnnouncements(announcementsStr string) error {
|
||||||
var list []map[string]interface{}
|
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||||
if err := json.Unmarshal([]byte(announcementsStr), &list); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("系统公告格式错误:%s", err.Error())
|
return err
|
||||||
}
|
}
|
||||||
if len(list) > 100 {
|
if len(list) > 100 {
|
||||||
return fmt.Errorf("系统公告数量不能超过100个")
|
return fmt.Errorf("系统公告数量不能超过100个")
|
||||||
@@ -158,9 +184,9 @@ func validateAnnouncements(announcementsStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateFAQ(faqStr string) error {
|
func validateFAQ(faqStr string) error {
|
||||||
var list []map[string]interface{}
|
list, err := parseJSONArray(faqStr, "FAQ信息")
|
||||||
if err := json.Unmarshal([]byte(faqStr), &list); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("FAQ信息格式错误:%s", err.Error())
|
return err
|
||||||
}
|
}
|
||||||
if len(list) > 100 {
|
if len(list) > 100 {
|
||||||
return fmt.Errorf("FAQ数量不能超过100个")
|
return fmt.Errorf("FAQ数量不能超过100个")
|
||||||
@@ -184,24 +210,79 @@ func validateFAQ(faqStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAnnouncements 获取系统公告
|
|
||||||
func GetAnnouncements() []map[string]interface{} {
|
func GetAnnouncements() []map[string]interface{} {
|
||||||
annStr := GetConsoleSetting().Announcements
|
return getJSONList(GetConsoleSetting().Announcements)
|
||||||
if annStr == "" {
|
|
||||||
return []map[string]interface{}{}
|
|
||||||
}
|
|
||||||
var ann []map[string]interface{}
|
|
||||||
_ = json.Unmarshal([]byte(annStr), &ann)
|
|
||||||
return ann
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFAQ 获取常见问题
|
|
||||||
func GetFAQ() []map[string]interface{} {
|
func GetFAQ() []map[string]interface{} {
|
||||||
faqStr := GetConsoleSetting().FAQ
|
return getJSONList(GetConsoleSetting().FAQ)
|
||||||
if faqStr == "" {
|
}
|
||||||
return []map[string]interface{}{}
|
|
||||||
|
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)
|
if len(groups) > 20 {
|
||||||
return faq
|
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.api_info': '',
|
||||||
'console_setting.announcements': '',
|
'console_setting.announcements': '',
|
||||||
'console_setting.faq': '',
|
'console_setting.faq': '',
|
||||||
'console_setting.uptime_kuma_url': '',
|
'console_setting.uptime_kuma_groups': '',
|
||||||
'console_setting.uptime_kuma_slug': '',
|
|
||||||
'console_setting.api_info_enabled': '',
|
'console_setting.api_info_enabled': '',
|
||||||
'console_setting.announcements_enabled': '',
|
'console_setting.announcements_enabled': '',
|
||||||
'console_setting.faq_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)",
|
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
|
||||||
"暂无常见问答": "No FAQ",
|
"暂无常见问答": "No FAQ",
|
||||||
"显示最新20条": "Display latest 20",
|
"显示最新20条": "Display latest 20",
|
||||||
"Uptime Kuma 服务地址": "Uptime Kuma service address",
|
"Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma monitoring category management, you can configure multiple monitoring categories for service status display (maximum 20)",
|
||||||
"状态页面 Slug": "Status page slug",
|
"添加分类": "Add Category",
|
||||||
"请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com",
|
"分类名称": "Category Name",
|
||||||
"请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status",
|
"Uptime Kuma地址": "Uptime Kuma Address",
|
||||||
"Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty",
|
"状态页面Slug": "Status Page Slug",
|
||||||
"请输入有效的 URL 地址": "Please enter a valid URL address",
|
"请输入分类名称,如:OpenAI、Claude等": "Please enter the category name, such as: OpenAI, Claude, etc.",
|
||||||
"状态页面 Slug 不能为空": "Status page slug cannot be empty",
|
"请输入Uptime Kuma服务地址,如:https://status.example.com": "Please enter the Uptime Kuma service address, such as: https://status.example.com",
|
||||||
"Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens",
|
"请输入状态页面的Slug,如:my-status": "Please enter the slug for the status page, such as: my-status",
|
||||||
"请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address",
|
"确定要删除此分类吗?": "Are you sure you want to delete this category?",
|
||||||
"请输入状态页面 Slug": "Please enter the status page slug",
|
|
||||||
"配置": "Configure",
|
"配置": "Configure",
|
||||||
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
|
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
|
||||||
"服务可用性": "Service Status",
|
"服务可用性": "Service Status",
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Timeline,
|
Timeline,
|
||||||
Collapse,
|
Collapse,
|
||||||
Progress
|
Progress,
|
||||||
|
Divider
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -215,6 +216,7 @@ const Detail = (props) => {
|
|||||||
const announcementScrollRef = useRef(null);
|
const announcementScrollRef = useRef(null);
|
||||||
const faqScrollRef = useRef(null);
|
const faqScrollRef = useRef(null);
|
||||||
const uptimeScrollRef = useRef(null);
|
const uptimeScrollRef = useRef(null);
|
||||||
|
const uptimeTabScrollRefs = useRef({});
|
||||||
|
|
||||||
// ========== Additional State for scroll hints ==========
|
// ========== Additional State for scroll hints ==========
|
||||||
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
|
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
|
||||||
@@ -224,6 +226,7 @@ const Detail = (props) => {
|
|||||||
// ========== Uptime data ==========
|
// ========== Uptime data ==========
|
||||||
const [uptimeData, setUptimeData] = useState([]);
|
const [uptimeData, setUptimeData] = useState([]);
|
||||||
const [uptimeLoading, setUptimeLoading] = useState(false);
|
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||||
|
const [activeUptimeTab, setActiveUptimeTab] = useState('');
|
||||||
|
|
||||||
// ========== Props Destructuring ==========
|
// ========== Props Destructuring ==========
|
||||||
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
||||||
@@ -579,6 +582,9 @@ const Detail = (props) => {
|
|||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setUptimeData(data || []);
|
setUptimeData(data || []);
|
||||||
|
if (data && data.length > 0 && !activeUptimeTab) {
|
||||||
|
setActiveUptimeTab(data[0].categoryName);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@@ -587,7 +593,7 @@ const Detail = (props) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setUptimeLoading(false);
|
setUptimeLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeUptimeTab]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
await Promise.all([loadQuotaData(), loadUptimeData()]);
|
await Promise.all([loadQuotaData(), loadUptimeData()]);
|
||||||
@@ -644,10 +650,18 @@ const Detail = (props) => {
|
|||||||
checkApiScrollable();
|
checkApiScrollable();
|
||||||
checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
|
checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
|
||||||
checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
|
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);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [uptimeData]);
|
}, [uptimeData, activeUptimeTab]);
|
||||||
|
|
||||||
const getUserData = async () => {
|
const getUserData = async () => {
|
||||||
let res = await API.get(`/api/user/self`);
|
let res = await API.get(`/api/user/self`);
|
||||||
@@ -883,7 +897,6 @@ const Detail = (props) => {
|
|||||||
|
|
||||||
const announcementData = useMemo(() => {
|
const announcementData = useMemo(() => {
|
||||||
const announcements = statusState?.status?.announcements || [];
|
const announcements = statusState?.status?.announcements || [];
|
||||||
// 处理后台配置的公告数据,自动生成相对时间
|
|
||||||
return announcements.map(item => ({
|
return announcements.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
time: getRelativeTime(item.publishDate)
|
time: getRelativeTime(item.publishDate)
|
||||||
@@ -894,6 +907,68 @@ const Detail = (props) => {
|
|||||||
return statusState?.status?.faq || [];
|
return statusState?.status?.faq || [];
|
||||||
}, [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 ==========
|
// ========== Hooks - Effects ==========
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserData();
|
getUserData();
|
||||||
@@ -1127,8 +1202,8 @@ const Detail = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
image={<IllustrationConstruction />}
|
||||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
darkModeImage={<IllustrationConstructionDark />}
|
||||||
title={t('暂无API信息')}
|
title={t('暂无API信息')}
|
||||||
description={t('请联系管理员在系统设置中配置API信息')}
|
description={t('请联系管理员在系统设置中配置API信息')}
|
||||||
style={{ padding: '12px' }}
|
style={{ padding: '12px' }}
|
||||||
@@ -1199,8 +1274,8 @@ const Detail = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
image={<IllustrationConstruction />}
|
||||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
darkModeImage={<IllustrationConstructionDark />}
|
||||||
title={t('暂无系统公告')}
|
title={t('暂无系统公告')}
|
||||||
description={t('请联系管理员在系统设置中配置公告信息')}
|
description={t('请联系管理员在系统设置中配置公告信息')}
|
||||||
style={{ padding: '12px' }}
|
style={{ padding: '12px' }}
|
||||||
@@ -1227,6 +1302,7 @@ const Detail = (props) => {
|
|||||||
{t('常见问答')}
|
{t('常见问答')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
>
|
>
|
||||||
<div className="card-content-container">
|
<div className="card-content-container">
|
||||||
<div
|
<div
|
||||||
@@ -1253,8 +1329,8 @@ const Detail = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
image={<IllustrationConstruction />}
|
||||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
darkModeImage={<IllustrationConstructionDark />}
|
||||||
title={t('暂无常见问答')}
|
title={t('暂无常见问答')}
|
||||||
description={t('请联系管理员在系统设置中配置常见问答')}
|
description={t('请联系管理员在系统设置中配置常见问答')}
|
||||||
style={{ padding: '12px' }}
|
style={{ padding: '12px' }}
|
||||||
@@ -1274,7 +1350,7 @@ const Detail = (props) => {
|
|||||||
{uptimeEnabled && (
|
{uptimeEnabled && (
|
||||||
<Card
|
<Card
|
||||||
{...CARD_PROPS}
|
{...CARD_PROPS}
|
||||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1291,11 +1367,93 @@ const Detail = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
footer={uptimeData.length > 0 ? (
|
bodyStyle={{ padding: 0 }}
|
||||||
<Card
|
>
|
||||||
bordered={false}
|
{/* 内容区域 */}
|
||||||
className="!rounded-2xl backdrop-blur !shadow-none"
|
<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">
|
<div className="flex flex-wrap gap-3 text-xs justify-center">
|
||||||
{uptimeLegendData.map((legend, index) => (
|
{uptimeLegendData.map((legend, index) => (
|
||||||
<div key={index} className="flex items-center gap-1">
|
<div key={index} className="flex items-center gap-1">
|
||||||
@@ -1307,63 +1465,8 @@ const Detail = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
) : 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>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Form,
|
|
||||||
Button,
|
Button,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Form,
|
||||||
Typography,
|
Typography,
|
||||||
Row,
|
Empty,
|
||||||
Col,
|
Divider,
|
||||||
Switch,
|
Modal,
|
||||||
|
Switch
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
|
IllustrationNoResult,
|
||||||
|
IllustrationNoResultDark
|
||||||
|
} from '@douyinfe/semi-illustrations';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
Activity
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -19,69 +29,242 @@ const { Text } = Typography;
|
|||||||
const SettingsUptimeKuma = ({ options, refresh }) => {
|
const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||||
const { t } = useTranslation();
|
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 [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 [panelEnabled, setPanelEnabled] = useState(true);
|
||||||
const formApiRef = useRef(null);
|
|
||||||
|
|
||||||
const initValues = useMemo(() => ({
|
const columns = [
|
||||||
uptimeKumaUrl: options?.['console_setting.uptime_kuma_url'] || '',
|
{
|
||||||
uptimeKumaSlug: options?.['console_setting.uptime_kuma_slug'] || ''
|
title: t('分类名称'),
|
||||||
}), [options?.['console_setting.uptime_kuma_url'], options?.['console_setting.uptime_kuma_slug']]);
|
dataIndex: 'categoryName',
|
||||||
|
key: 'categoryName',
|
||||||
useEffect(() => {
|
render: (text) => (
|
||||||
if (formApiRef.current) {
|
<div style={{
|
||||||
formApiRef.current.setValues(initValues, { isOverride: true });
|
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 updateOption = async (key, value) => {
|
||||||
const enabledStr = options?.['console_setting.uptime_kuma_enabled'];
|
const res = await API.put('/api/option/', {
|
||||||
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
|
key,
|
||||||
}, [options?.['console_setting.uptime_kuma_enabled']]);
|
value,
|
||||||
|
});
|
||||||
const handleSave = async () => {
|
const { success, message } = res.data;
|
||||||
const api = formApiRef.current;
|
if (success) {
|
||||||
if (!api) {
|
showSuccess('Uptime Kuma配置已更新');
|
||||||
showError(t('表单未初始化'));
|
if (refresh) refresh();
|
||||||
return;
|
} else {
|
||||||
|
showError(message);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitUptimeGroups = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
|
const groupsJson = JSON.stringify(uptimeGroupsList);
|
||||||
|
await updateOption('console_setting.uptime_kuma_groups', groupsJson);
|
||||||
const trimmedUrl = (uptimeKumaUrl || '').trim();
|
setHasChanges(false);
|
||||||
const trimmedSlug = (uptimeKumaSlug || '').trim();
|
} catch (error) {
|
||||||
|
console.error('Uptime Kuma配置更新失败', error);
|
||||||
if (trimmedUrl === options?.['console_setting.uptime_kuma_url'] && trimmedSlug === options?.['console_setting.uptime_kuma_slug']) {
|
showError('Uptime Kuma配置更新失败');
|
||||||
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('保存失败,请重试'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 handleToggleEnabled = async (checked) => {
|
||||||
const newValue = checked ? 'true' : 'false';
|
const newValue = checked ? 'true' : 'false';
|
||||||
try {
|
try {
|
||||||
@@ -101,46 +284,65 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidUrl = useCallback((string) => {
|
const handleBatchDelete = () => {
|
||||||
try {
|
if (selectedRowKeys.length === 0) {
|
||||||
new URL(string);
|
showError('请先选择要删除的分类');
|
||||||
return true;
|
return;
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
|
||||||
|
setUptimeGroupsList(newList);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
setHasChanges(true);
|
||||||
|
showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`);
|
||||||
|
};
|
||||||
|
|
||||||
const renderHeader = () => (
|
const renderHeader = () => (
|
||||||
<div className="flex flex-col w-full">
|
<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">
|
<div className="flex items-center text-blue-500">
|
||||||
<Activity size={16} className="mr-2" />
|
<Activity size={16} className="mr-2" />
|
||||||
<Text>
|
<Text>{t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}</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>
|
|
||||||
</div>
|
</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
|
<Button
|
||||||
icon={<Save size={14} />}
|
icon={<Save size={14} />}
|
||||||
theme='solid'
|
onClick={submitUptimeGroups}
|
||||||
type='primary'
|
|
||||||
onClick={handleSave}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className="!rounded-full"
|
disabled={!hasChanges}
|
||||||
|
type='secondary'
|
||||||
|
className="!rounded-full w-full md:w-auto"
|
||||||
>
|
>
|
||||||
{t('保存设置')}
|
{t('保存设置')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<div className="order-1 md:order-2 flex items-center gap-2">
|
||||||
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
|
||||||
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,67 +350,132 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<Form.Section text={renderHeader()}>
|
<>
|
||||||
<Form
|
<Form.Section text={renderHeader()}>
|
||||||
layout="vertical"
|
<Table
|
||||||
autoScrollToError
|
columns={columns}
|
||||||
initValues={initValues}
|
dataSource={getCurrentPageData()}
|
||||||
getFormApi={(api) => {
|
rowSelection={rowSelection}
|
||||||
formApiRef.current = api;
|
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]}>
|
<Text>{t('确定要删除此分类吗?')}</Text>
|
||||||
<Col xs={24} md={12}>
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user