- 移除端口公网监听的渲染

- 增加探针离线/上线的监控
- 优化自定义 webhook
- 增加暗黑模式
This commit is contained in:
dushixiang committed 2025-11-27 18:38:57 +08:00
1 parent b5e188fa1d
commit 214158d022
23 files changed
+1177 -494

No files matched your search

+3 -1
View File
@@ -18,10 +18,12 @@ require (
github.com/jpillora/backoff v1.0.0
github.com/kardianos/service v1.2.4
github.com/labstack/echo/v4 v4.13.4
github.com/minio/selfupdate v0.6.0
github.com/prometheus-community/pro-bing v0.7.0
github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.1
github.com/valyala/fasttemplate v1.2.2
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.33.0
@@ -31,6 +33,7 @@ require (
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
@@ -66,7 +69,6 @@ require (
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
+20
View File
@@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
@@ -99,6 +101,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -154,27 +158,43 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+5
View File
@@ -71,6 +71,11 @@ func setup(app *orz.App) error {
app.Logger().Error("初始化默认属性配置失败", zap.Error(err))
// 不返回错误,继续启动
}
// 初始化探针的状态全部为离线
if err := components.AgentService.InitStatus(ctx); err != nil {
app.Logger().Error("初始化探针状态失败", zap.Error(err))
// 不返回错误,继续启动
}
// 启动WebSocket管理器
go components.WSManager.Run(ctx)
+1 -6
View File
@@ -911,12 +911,7 @@ register_agent() {
echo_info "正在注册探针..."
if /usr/local/bin/$AGENT_NAME register --endpoint "$endpoint" --token "$token"; then
echo_info "探针注册成功"
else
echo_error "探针注册失败"
exit 1
fi
/usr/local/bin/$AGENT_NAME register --endpoint "$endpoint" --token "$token"
}
# 主流程
+4
View File
@@ -41,6 +41,10 @@ type AlertRules struct {
// 服务下线告警配置
ServiceEnabled bool `json:"serviceEnabled"` // 是否启用服务下线告警
ServiceDuration int `json:"serviceDuration"` // 持续时间(秒)
// 探针离线告警配置
AgentOfflineEnabled bool `json:"agentOfflineEnabled"` // 是否启用探针离线告警
AgentOfflineDuration int `json:"agentOfflineDuration"` // 持续时间(秒)
}
func (AlertConfig) TableName() string {
+16 -1
View File
@@ -24,7 +24,22 @@ type NotificationChannelConfig struct {
// dingtalk: { "secretKey": "xxx", "signSecret": "xxx" }
// wecom: { "secretKey": "xxx" }
// feishu: { "secretKey": "xxx", "signSecret": "xxx" }
// webhook: { "url": "https://..." }
// webhook: {
// "url": "https://...",
// "method": "POST", // 可选:GET, POST, PUT, PATCH, DELETE,默认 POST
// "headers": {"key": "value"}, // 可选:自定义请求头
// "bodyTemplate": "json" // 可选:json(默认), form, custom
// "customBody": "" // 当 bodyTemplate 为 custom 时使用,支持变量替换
// }
// WebhookConfig 自定义 Webhook 配置结构
type WebhookConfig struct {
URL string `json:"url"` // Webhook URL
Method string `json:"method,omitempty"` // 请求方法,默认 POST
Headers map[string]string `json:"headers,omitempty"` // 自定义请求头
BodyTemplate string `json:"bodyTemplate,omitempty"` // 请求体模板:json, form, custom
CustomBody string `json:"customBody,omitempty"` // 自定义请求体模板(支持变量)
}
type SystemConfig struct {
SystemNameZh string `json:"systemNameZh"` // 系统名称(中文)
+2 -3
View File
@@ -2,7 +2,6 @@ package repo
import (
"context"
"time"
"github.com/dushixiang/pika/internal/models"
"github.com/go-orz/orz"
@@ -22,13 +21,13 @@ func NewAgentRepo(db *gorm.DB) *AgentRepo {
}
// UpdateStatus 更新探针状态
func (r *AgentRepo) UpdateStatus(ctx context.Context, agentID string, status int) error {
func (r *AgentRepo) UpdateStatus(ctx context.Context, agentID string, status int, lastSeenAt int64) error {
return r.db.WithContext(ctx).
Model(&models.Agent{}).
Where("id = ?", agentID).
Updates(map[string]interface{}{
"status": status,
"last_seen_at": time.Now().UnixMilli(),
"last_seen_at": lastSeenAt,
}).Error
}
+14 -1
View File
@@ -109,7 +109,7 @@ func (s *AgentService) RegisterAgent(ctx context.Context, ip string, info *proto
// UpdateAgentStatus 更新探针状态
func (s *AgentService) UpdateAgentStatus(ctx context.Context, agentID string, status int) error {
return s.AgentRepo.UpdateStatus(ctx, agentID, status)
return s.AgentRepo.UpdateStatus(ctx, agentID, status, time.Now().UnixMilli())
}
// GetAgent 获取探针信息
@@ -790,3 +790,16 @@ func (s *AgentService) GetAgentByAuth(ctx context.Context, id string, isAuthenti
func (s *AgentService) GetAllTags(ctx context.Context) ([]string, error) {
return s.AgentRepo.GetAllTags(ctx)
}
func (s *AgentService) InitStatus(ctx context.Context) error {
agents, err := s.AgentRepo.FindAll(ctx)
if err != nil {
return err
}
for _, agent := range agents {
if err := s.AgentRepo.UpdateStatus(ctx, agent.ID, 0, 0); err != nil {
return err
}
}
return nil
}
+156
View File
@@ -354,6 +354,13 @@ func (s *AlertService) CheckMonitorAlerts(ctx context.Context) error {
s.logger.Error("检查服务下线告警失败", zap.Error(err))
}
}
// 检查探针离线告警
if config.Rules.AgentOfflineEnabled {
if err := s.checkAgentOfflineAlerts(ctx, &config, now); err != nil {
s.logger.Error("检查探针离线告警失败", zap.Error(err))
}
}
}
return nil
@@ -710,3 +717,152 @@ func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *mode
state.IsFiring = false
state.LastRecordID = 0
}
// checkAgentOfflineAlerts 检查探针离线告警
func (s *AlertService) checkAgentOfflineAlerts(ctx context.Context, config *models.AlertConfig, now int64) error {
// 获取所有探针
agents, err := s.agentRepo.FindAll(ctx)
if err != nil {
return err
}
for _, agent := range agents {
stateKey := fmt.Sprintf("%s:%s:agent_offline:%s", config.AgentID, config.ID, agent.ID)
s.mu.Lock()
state, exists := s.states[stateKey]
if !exists {
state = &models.AlertState{
AgentID: agent.ID,
ConfigID: config.ID,
AlertType: "agent_offline",
Threshold: 0,
Duration: config.Rules.AgentOfflineDuration,
}
s.states[stateKey] = state
}
s.mu.Unlock()
state.LastCheckTime = now
// 计算探针离线时间(秒)
offlineSeconds := (now - agent.LastSeenAt) / 1000
// 检查探针是否离线超过阈值
if offlineSeconds >= int64(config.Rules.AgentOfflineDuration) {
// 探针离线超过阈值
if !state.IsFiring {
// 触发告警
s.fireAgentOfflineAlert(ctx, config, &agent, state, offlineSeconds, now)
}
} else {
// 探针在线或离线时间未达到阈值
if state.IsFiring {
// 从告警状态变为恢复状态
s.resolveAgentOfflineAlert(ctx, config, &agent, state)
}
}
}
return nil
}
// fireAgentOfflineAlert 触发探针离线告警
func (s *AlertService) fireAgentOfflineAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, state *models.AlertState, offlineSeconds int64, now int64) {
s.logger.Info("触发探针离线告警",
zap.String("agentId", agent.ID),
zap.String("agentName", agent.Name),
zap.Int64("offlineSeconds", offlineSeconds),
zap.Int("threshold", state.Duration),
)
// 创建告警记录
record := &models.AlertRecord{
AgentID: agent.ID,
ConfigID: config.ID,
ConfigName: config.Name,
AlertType: "agent_offline",
Message: fmt.Sprintf("探针 %s 已离线%d秒,超过阈值%d秒", agent.Name, offlineSeconds, state.Duration),
Threshold: float64(state.Duration),
ActualValue: float64(offlineSeconds),
Level: "critical",
Status: "firing",
FiredAt: now,
CreatedAt: now,
}
err := s.alertRepo.CreateAlertRecord(ctx, record)
if err != nil {
s.logger.Error("创建探针离线告警记录失败", zap.Error(err))
return
}
state.IsFiring = true
state.LastRecordID = record.ID
// 发送通知
go func() {
channelConfigs, err := s.propertyService.GetNotificationChannelConfigs(context.Background())
if err != nil {
s.logger.Error("获取通知渠道配置失败", zap.Error(err))
return
}
var enabledChannels []models.NotificationChannelConfig
for _, channel := range channelConfigs {
if channel.Enabled {
enabledChannels = append(enabledChannels, channel)
}
}
if err := s.notifier.SendNotificationByConfigs(context.Background(), enabledChannels, record, agent); err != nil {
s.logger.Error("发送探针离线告警通知失败", zap.Error(err))
}
}()
}
// resolveAgentOfflineAlert 恢复探针离线告警
func (s *AlertService) resolveAgentOfflineAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, state *models.AlertState) {
s.logger.Info("探针离线告警恢复",
zap.String("agentId", agent.ID),
zap.String("agentName", agent.Name),
)
// 更新告警记录状态
if state.LastRecordID > 0 {
existingRecord, err := s.alertRepo.GetLatestAlertRecord(ctx, config.ID, "agent_offline")
if err == nil && existingRecord != nil {
existingRecord.Status = "resolved"
existingRecord.ResolvedAt = time.Now().UnixMilli()
existingRecord.UpdatedAt = time.Now().UnixMilli()
err = s.alertRepo.UpdateAlertRecord(ctx, existingRecord)
if err != nil {
s.logger.Error("更新探针离线告警记录失败", zap.Error(err))
} else {
// 发送恢复通知
go func() {
channelConfigs, err := s.propertyService.GetNotificationChannelConfigs(context.Background())
if err != nil {
s.logger.Error("获取通知渠道配置失败", zap.Error(err))
return
}
var enabledChannels []models.NotificationChannelConfig
for _, channel := range channelConfigs {
if channel.Enabled {
enabledChannels = append(enabledChannels, channel)
}
}
if err := s.notifier.SendNotificationByConfigs(context.Background(), enabledChannels, existingRecord, agent); err != nil {
s.logger.Error("发送探针恢复通知失败", zap.Error(err))
}
}()
}
}
}
state.IsFiring = false
state.LastRecordID = 0
}
+197 -20
View File
@@ -10,9 +10,12 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dushixiang/pika/internal/models"
"github.com/valyala/fasttemplate"
"go.uber.org/zap"
)
@@ -185,19 +188,182 @@ func (n *Notifier) sendFeishu(ctx context.Context, webhook, message string) erro
}
// sendCustomWebhook 发送自定义Webhook
func (n *Notifier) sendCustomWebhook(ctx context.Context, webhook string, message string) error {
// 发送完整的告警记录和探针信息
body := map[string]interface{}{
"msg_type": "text",
"text": map[string]string{
"content": message,
},
func (n *Notifier) sendCustomWebhook(ctx context.Context, config map[string]interface{}, agent *models.Agent, record *models.AlertRecord) error {
// 解析配置
webhookURL, ok := config["url"].(string)
if !ok || webhookURL == "" {
return fmt.Errorf("自定义Webhook配置缺少 url")
}
_, err := n.sendJSONRequest(ctx, webhook, body)
if err != nil {
return err
// 获取请求方法,默认 POST
method := "POST"
if m, ok := config["method"].(string); ok && m != "" {
method = strings.ToUpper(m)
}
// 获取自定义请求头
headers := make(map[string]string)
if h, ok := config["headers"].(map[string]interface{}); ok {
for k, v := range h {
if strVal, ok := v.(string); ok {
headers[k] = strVal
}
}
}
// 获取请求体模板类型,默认 json
bodyTemplate := "json"
if bt, ok := config["bodyTemplate"].(string); ok && bt != "" {
bodyTemplate = bt
}
// 构建消息内容
message := n.buildMessage(agent, record)
// 根据模板类型构建请求体
var reqBody io.Reader
var contentType string
switch bodyTemplate {
case "json":
// JSON 格式
body := map[string]interface{}{
"msg_type": "text",
"text": map[string]string{
"content": message,
},
"agent": map[string]interface{}{
"id": agent.ID,
"name": agent.Name,
"hostname": agent.Hostname,
"ip": agent.IP,
},
"alert": map[string]interface{}{
"type": record.AlertType,
"level": record.Level,
"status": record.Status,
"message": record.Message,
"threshold": record.Threshold,
"actualValue": record.ActualValue,
"firedAt": record.FiredAt,
"resolvedAt": record.ResolvedAt,
},
}
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("序列化 JSON 失败: %w", err)
}
reqBody = bytes.NewReader(data)
contentType = "application/json"
case "form":
// Form 表单格式
formData := url.Values{}
formData.Set("message", message)
formData.Set("agent_id", agent.ID)
formData.Set("agent_name", agent.Name)
formData.Set("agent_hostname", agent.Hostname)
formData.Set("agent_ip", agent.IP)
formData.Set("alert_type", record.AlertType)
formData.Set("alert_level", record.Level)
formData.Set("alert_status", record.Status)
formData.Set("alert_message", record.Message)
formData.Set("threshold", fmt.Sprintf("%.2f", record.Threshold))
formData.Set("actual_value", fmt.Sprintf("%.2f", record.ActualValue))
formData.Set("fired_at", fmt.Sprintf("%d", record.FiredAt))
if record.ResolvedAt > 0 {
formData.Set("resolved_at", fmt.Sprintf("%d", record.ResolvedAt))
}
reqBody = strings.NewReader(formData.Encode())
contentType = "application/x-www-form-urlencoded"
case "custom":
// 自定义模板,支持变量替换
customBody, ok := config["customBody"].(string)
if !ok || customBody == "" {
return fmt.Errorf("使用 custom 模板时必须提供 customBody")
}
// 使用 fasttemplate 进行变量替换
t := fasttemplate.New(customBody, "{{", "}}")
bodyStr := t.ExecuteFuncString(func(w io.Writer, tag string) (int, error) {
switch tag {
case "message":
return w.Write([]byte(message))
case "agent.id":
return w.Write([]byte(agent.ID))
case "agent.name":
return w.Write([]byte(agent.Name))
case "agent.hostname":
return w.Write([]byte(agent.Hostname))
case "agent.ip":
return w.Write([]byte(agent.IP))
case "alert.type":
return w.Write([]byte(record.AlertType))
case "alert.level":
return w.Write([]byte(record.Level))
case "alert.status":
return w.Write([]byte(record.Status))
case "alert.message":
return w.Write([]byte(record.Message))
case "alert.threshold":
return w.Write([]byte(fmt.Sprintf("%.2f", record.Threshold)))
case "alert.actualValue":
return w.Write([]byte(fmt.Sprintf("%.2f", record.ActualValue)))
case "alert.firedAt":
return w.Write([]byte(fmt.Sprintf("%d", record.FiredAt)))
case "alert.resolvedAt":
return w.Write([]byte(fmt.Sprintf("%d", record.ResolvedAt)))
default:
// 未知变量保持原样
return w.Write([]byte("{{" + tag + "}}"))
}
})
reqBody = strings.NewReader(bodyStr)
contentType = "text/plain"
default:
return fmt.Errorf("不支持的 bodyTemplate: %s", bodyTemplate)
}
// 创建请求
req, err := http.NewRequestWithContext(ctx, method, webhookURL, reqBody)
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
// 设置 Content-Type
req.Header.Set("Content-Type", contentType)
// 设置自定义请求头
for k, v := range headers {
req.Header.Set(k, v)
}
// 发送请求
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("请求失败,状态码: %d, 响应: %s", resp.StatusCode, string(respBody))
}
n.logger.Info("自定义Webhook发送成功",
zap.String("url", webhookURL),
zap.String("method", method),
zap.String("response", string(respBody)),
)
return nil
}
@@ -279,13 +445,8 @@ func (n *Notifier) sendFeishuByConfig(ctx context.Context, config map[string]int
}
// sendWebhookByConfig 根据配置发送自定义Webhook
func (n *Notifier) sendWebhookByConfig(ctx context.Context, config map[string]interface{}, message string) error {
url, ok := config["url"].(string)
if !ok || url == "" {
return fmt.Errorf("自定义Webhook配置缺少 url")
}
return n.sendCustomWebhook(ctx, url, message)
func (n *Notifier) sendWebhookByConfig(ctx context.Context, config map[string]interface{}, agent *models.Agent, record *models.AlertRecord) error {
return n.sendCustomWebhook(ctx, config, agent, record)
}
// SendNotificationByConfig 根据新的配置结构发送通知
@@ -309,7 +470,7 @@ func (n *Notifier) SendNotificationByConfig(ctx context.Context, channelConfig *
case "feishu":
return n.sendFeishuByConfig(ctx, channelConfig.Config, message)
case "webhook":
return n.sendWebhookByConfig(ctx, channelConfig.Config, message)
return n.sendWebhookByConfig(ctx, channelConfig.Config, agent, record)
case "email":
// TODO: 实现邮件通知
return fmt.Errorf("邮件通知暂未实现")
@@ -354,7 +515,23 @@ func (n *Notifier) SendFeishuByConfig(ctx context.Context, config map[string]int
return n.sendFeishuByConfig(ctx, config, message)
}
// SendWebhookByConfig 导出方法供外部调用
// SendWebhookByConfig 导出方法供外部调用(测试用)
func (n *Notifier) SendWebhookByConfig(ctx context.Context, config map[string]interface{}, message string) error {
return n.sendWebhookByConfig(ctx, config, message)
// 为了测试,创建一个临时的 agent 和 record
agent := &models.Agent{
ID: "test-agent",
Name: "测试探针",
Hostname: "test-host",
IP: "127.0.0.1",
}
record := &models.AlertRecord{
AlertType: "test",
Level: "info",
Status: "firing",
Message: message,
Threshold: 0,
ActualValue: 0,
FiredAt: time.Now().UnixMilli(),
}
return n.sendWebhookByConfig(ctx, config, agent, record)
}
+13 -135
View File
@@ -5,16 +5,14 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/dushixiang/pika/pkg/agent/config"
"github.com/minio/selfupdate"
)
// VersionInfo 版本信息
@@ -145,23 +143,6 @@ func (u *Updater) downloadAndUpdate(versionInfo *VersionInfo) error {
log.Printf("📥 下载新版本: %s", versionInfo.Version)
downloadURL := u.cfg.GetDownloadURL()
if err := u.updateWithClient(downloadURL); err != nil {
return err
}
log.Printf("✅ 新版本已安装到: %s", u.executablePath)
return nil
}
// updateWithClient 使用实例的 httpClient 下载并更新
func (u *Updater) updateWithClient(downloadURL string) error {
execPath := u.executablePath
// 解析实际路径(处理符号链接)
execPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
return fmt.Errorf("解析可执行文件路径失败: %w", err)
}
// 下载文件
resp, err := u.httpClient.Get(downloadURL)
@@ -174,31 +155,18 @@ func (u *Updater) updateWithClient(downloadURL string) error {
return fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
}
// 创建临时文件
tmpFile := execPath + ".new"
out, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
defer out.Close()
defer os.Remove(tmpFile)
// 写入文件
written, err := io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
// 使用 selfupdate 应用更新
if err := selfupdate.Apply(resp.Body, selfupdate.Options{}); err != nil {
return fmt.Errorf("应用更新失败: %w", err)
}
log.Printf("下载完成,文件大小: %d 字节", written)
log.Printf("✅ 更新成功,进程即将退出,等待系统服务重启...")
// 根据操作系统选择不同的更新策略
if runtime.GOOS == "windows" {
// Windows: 使用批处理脚本延迟替换
return updateOnWindows(execPath, tmpFile)
}
// 退出当前进程,让系统服务管理器(systemd/supervisor等)自动重启
// 注意:这要求服务配置了自动重启(如 systemd 的 Restart=always)
os.Exit(1)
// Unix-like: 直接替换
return updateOnUnix(execPath, tmpFile)
return nil
}
// CheckUpdate 手动检查更新(用于命令行)
@@ -229,17 +197,6 @@ func CheckUpdate(updateURL, currentVer string) (*VersionInfo, error) {
// Update 手动更新(用于命令行)
func Update(downloadURL string) error {
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("获取可执行文件路径失败: %w", err)
}
// 解析实际路径(处理符号链接)
execPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
return fmt.Errorf("解析可执行文件路径失败: %w", err)
}
client := &http.Client{
Timeout: 300 * time.Second,
}
@@ -255,52 +212,12 @@ func Update(downloadURL string) error {
return fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
}
// 创建临时文件
tmpFile := execPath + ".new"
out, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
defer out.Close()
defer os.Remove(tmpFile)
// 写入文件
written, err := io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
// 使用 selfupdate 应用更新
if err := selfupdate.Apply(resp.Body, selfupdate.Options{}); err != nil {
return fmt.Errorf("应用更新失败: %w", err)
}
log.Printf("下载完成,文件大小: %d 字节", written)
// 根据操作系统选择不同的更新策略
if runtime.GOOS == "windows" {
// Windows: 使用批处理脚本延迟替换
return updateOnWindows(execPath, tmpFile)
}
// Unix-like: 直接替换
return updateOnUnix(execPath, tmpFile)
}
// updateOnUnix Unix-like 系统的更新逻辑
func updateOnUnix(execPath, tmpFile string) error {
// 备份旧版本
backupPath := execPath + ".bak"
if err := os.Rename(execPath, backupPath); err != nil {
return fmt.Errorf("备份旧版本失败: %w", err)
}
// 替换为新版本
if err := os.Rename(tmpFile, execPath); err != nil {
// 恢复备份
os.Rename(backupPath, execPath)
return fmt.Errorf("替换新版本失败: %w", err)
}
// 删除备份
os.Remove(backupPath)
log.Println("✅ 更新完成,进程即将退出,等待系统服务重启...")
log.Printf("✅ 更新成功,进程即将退出,等待系统服务重启...")
// 退出当前进程,让系统服务管理器(systemd/supervisor等)自动重启
// 注意:这要求服务配置了自动重启(如 systemd 的 Restart=always)
@@ -308,42 +225,3 @@ func updateOnUnix(execPath, tmpFile string) error {
return nil
}
// updateOnWindows Windows 系统的更新逻辑
func updateOnWindows(execPath, tmpFile string) error {
// 在 Windows 上,无法直接替换正在运行的可执行文件
// 策略: 创建一个批处理脚本来延迟替换和重启
batScript := execPath + ".update.bat"
batContent := fmt.Sprintf(`@echo off
timeout /t 2 /nobreak >nul
move /y "%s" "%s.bak"
move /y "%s" "%s"
del "%s.bak"
start "" "%s"
del "%%~f0"
`, execPath, execPath, tmpFile, execPath, execPath, execPath)
if err := os.WriteFile(batScript, []byte(batContent), 0755); err != nil {
return fmt.Errorf("创建更新脚本失败: %w", err)
}
log.Println("✅ 更新完成,准备重启进程...")
// 启动批处理脚本并退出当前进程
cmd := exec.Command("cmd.exe", "/C", batScript)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
os.Remove(batScript)
return fmt.Errorf("启动更新脚本失败: %w", err)
}
// 让系统服务管理器来重启(当前进程退出后)
log.Println("进程即将退出,等待系统服务重启...")
os.Exit(0)
return nil
}
+6 -6
View File
@@ -5,12 +5,12 @@ const PublicFooter = () => {
const icpCode = window.SystemConfig?.ICPCode || '';
return (
<footer className="border-t border-slate-100 bg-gradient-to-b from-white to-slate-50">
<footer className="border-t border-slate-100 dark:border-slate-700 bg-gradient-to-b from-white to-slate-50 dark:from-slate-900 dark:to-slate-800">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* 底部版权信息 */}
<div className="py-6">
<div
className="flex flex-col items-center justify-between gap-3 text-xs text-slate-500 sm:flex-row">
className="flex flex-col items-center justify-between gap-3 text-xs text-slate-500 dark:text-slate-400 sm:flex-row">
<div className="flex items-center gap-1">
<span>© {currentYear}</span>
{/* GitHub 链接 */}
@@ -20,20 +20,20 @@ const PublicFooter = () => {
rel="noopener noreferrer"
title="查看 GitHub 仓库"
>
<div className={' text-slate-500 underline'}>Pika Monitor</div>
<div className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 underline transition-colors">Pika Monitor</div>
{/*<img src={GithubSvg} className="h-4 w-4" alt="GitHub"/>*/}
</a>
<span className="text-slate-300">·</span>
<span className="text-slate-300 dark:text-slate-600">·</span>
<span>保持洞察,稳定运行</span>
{/* ICP 备案号 */}
{icpCode && (
<>
<span className="text-slate-300">·</span>
<span className="text-slate-300 dark:text-slate-600">·</span>
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-700 transition-colors"
className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
{icpCode}
</a>
+105 -17
View File
@@ -1,7 +1,8 @@
import {useEffect, useState} from 'react';
import {Activity, LayoutGrid, List, LogIn, Server, Settings} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {Activity, LayoutGrid, List, LogIn, Moon, Server, Settings, Sun} from 'lucide-react';
import {getCurrentUser} from '../api/auth';
import {Link, useLocation} from "react-router-dom";
import {flushSync} from "react-dom";
interface PublicHeaderProps {
viewMode?: 'grid' | 'list';
@@ -15,6 +16,8 @@ const PublicHeader = ({
showViewToggle = false
}: PublicHeaderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const darkModeButtonRef = useRef<HTMLButtonElement>(null);
let location = useLocation();
useEffect(() => {
@@ -33,20 +36,90 @@ const PublicHeader = ({
setIsLoggedIn(true);
})
.catch(() => {
// token 无效,清除本地存储
// token 无效,清除本地存储
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
setIsLoggedIn(false);
});
}, []);
// 初始化暗色主题
useEffect(() => {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark);
setIsDarkMode(shouldBeDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);
// 切换暗色主题的函数,带动画效果
const toggleDarkMode = async (newIsDarkMode: boolean) => {
if (
!darkModeButtonRef.current ||
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
// 如果不支持 View Transition API 或用户偏好减少动画,直接切换
setIsDarkMode(newIsDarkMode);
if (newIsDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light');
return;
}
await document.startViewTransition(() => {
flushSync(() => {
setIsDarkMode(newIsDarkMode);
if (newIsDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light');
});
}).ready;
const {top, left, width, height} = darkModeButtonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(
Math.max(left, right),
Math.max(top, bottom),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
};
// 判断导航是否激活
const currentPath = location.pathname;
const isDeviceActive = currentPath === '/';
const isMonitorActive = currentPath === '/monitors';
return (
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white">
<header
className="sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-gradient-to-r dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4">
{/* 左侧:品牌和导航 */}
@@ -62,10 +135,10 @@ const PublicHeader = ({
}}
/>
<div className="hidden md:block">
<p className="text-[10px] font-semibold uppercase tracking-[0.3em] text-blue-600">
<p className="text-[10px] font-semibold uppercase tracking-[0.3em] text-blue-600 dark:text-sky-300">
{window.SystemConfig?.SystemNameEn}
</p>
<h1 className="text-sm font-bold text-slate-900">
<h1 className="text-sm font-bold text-slate-900 dark:text-slate-50">
{window.SystemConfig?.SystemNameZh}
</h1>
</div>
@@ -77,8 +150,8 @@ const PublicHeader = ({
<div
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium transition-all ${
isDeviceActive
? 'bg-blue-50 text-blue-600'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
? 'bg-blue-50 dark:bg-sky-500/15 text-blue-600 dark:text-sky-200'
: 'text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-50'
}`}
>
<Server className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
@@ -89,8 +162,8 @@ const PublicHeader = ({
<div
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium transition-all ${
isMonitorActive
? 'bg-blue-50 text-blue-600'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
? 'bg-blue-50 dark:bg-sky-500/15 text-blue-600 dark:text-sky-200'
: 'text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-50'
}`}>
<Activity className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">服务监控</span>
@@ -101,17 +174,32 @@ const PublicHeader = ({
{/* 右侧:功能区 */}
<div className="flex items-center gap-2 sm:gap-3">
{/* 暗色主题切换 */}
<button
ref={darkModeButtonRef}
type="button"
onClick={() => toggleDarkMode(!isDarkMode)}
className="inline-flex items-center rounded-lg p-1.5 sm:p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800 transition-all"
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDarkMode ? (
<Sun className="h-4 w-4 sm:h-5 sm:w-5"/>
) : (
<Moon className="h-4 w-4 sm:h-5 sm:w-5"/>
)}
</button>
{/* 视图切换 */}
{showViewToggle && viewMode && onViewModeChange && (
<div
className="hidden sm:inline-flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5">
className="hidden sm:inline-flex items-center gap-0.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/70 p-0.5">
<button
type="button"
onClick={() => onViewModeChange('grid')}
className={`inline-flex items-center rounded-md p-1.5 transition-all cursor-pointer ${
viewMode === 'grid'
? 'bg-white text-blue-600'
: 'text-slate-500 hover:text-slate-900'
? 'bg-white dark:bg-slate-900 text-blue-600 dark:text-sky-200 shadow-sm dark:shadow-slate-900/60'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100'
}`}
title="网格视图"
>
@@ -122,8 +210,8 @@ const PublicHeader = ({
onClick={() => onViewModeChange('list')}
className={`inline-flex items-center rounded-md p-1.5 transition-all cursor-pointer ${
viewMode === 'list'
? 'bg-white text-blue-600'
: 'text-slate-500 hover:text-slate-900'
? 'bg-white dark:bg-slate-900 text-blue-600 dark:text-sky-200 shadow-sm dark:shadow-slate-900/60'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100'
}`}
title="列表视图"
>
@@ -136,7 +224,7 @@ const PublicHeader = ({
{isLoggedIn ? (
<a
href="/admin"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 transition-all"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400 transition-all"
>
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">管理后台</span>
@@ -144,7 +232,7 @@ const PublicHeader = ({
) : (
<a
href="/login"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 transition-all"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400 transition-all"
>
<LogIn className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">登录</span>
+12
View File
@@ -1,5 +1,8 @@
@import "tailwindcss";
/* Tailwind v4 暗色模式配置 */
@variant dark (&:where(.dark, .dark *));
/* 全局样式 */
* {
scrollbar-width: thin;
@@ -22,4 +25,13 @@
::-webkit-scrollbar-thumb:hover {
background: rgb(156, 163, 175);
}
/* 暗色模式滚动条 */
.dark ::-webkit-scrollbar-thumb {
background: rgb(71, 85, 105);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgb(100, 116, 139);
}
-8
View File
@@ -268,14 +268,6 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
key: 'address',
render: (record: any) => `${record.address}:${record.port}`,
},
{
title: '公网暴露',
dataIndex: 'isPublic',
key: 'isPublic',
width: 100,
render: (isPublic: boolean) => isPublic ?
<Tag color="warning">是</Tag> : <Tag color="default">否</Tag>
},
{title: '进程', dataIndex: 'processName', key: 'processName'},
{title: 'PID', dataIndex: 'processPid', key: 'processPid', width: 80},
];
+62 -60
View File
@@ -59,21 +59,21 @@ const formatDateTime = (timestamp: number): string => {
const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00');
const LoadingSpinner = () => (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-slate-400"/>
<p className="text-sm text-slate-500">加载监控数据中...</p>
<Loader2 className="h-8 w-8 animate-spin text-slate-400 dark:text-slate-400"/>
<p className="text-sm text-slate-500 dark:text-slate-400">加载监控数据中...</p>
</div>
</div>
);
const EmptyState = ({message = '监控数据不存在'}: { message?: string }) => (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-900 text-slate-400 dark:text-slate-300">
<Shield className="h-8 w-8"/>
</div>
<p className="text-sm text-slate-500">{message}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{message}</p>
</div>
</div>
);
@@ -89,13 +89,13 @@ const Card = ({
action?: ReactNode;
children: ReactNode;
}) => (
<section className="rounded-3xl border border-slate-100 bg-white/95 p-6 shadow-sm">
<section className="rounded-3xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60 p-6 shadow-sm">
{(title || description || action) && (
<div
className="flex flex-col gap-3 border-b border-slate-100 pb-4 sm:flex-row sm:items-start sm:justify-between">
className="flex flex-col gap-3 border-b border-slate-100 dark:border-slate-800 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div>
{title ? <h2 className="text-lg font-semibold text-slate-900">{title}</h2> : null}
{description ? <p className="mt-1 text-sm text-slate-500">{description}</p> : null}
{title ? <h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">{title}</h2> : null}
{description ? <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{description}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
@@ -105,16 +105,16 @@ const Card = ({
);
const StatusBadge = ({status}: { status: string }) => {
let containerClass = 'bg-slate-100 text-slate-600';
let containerClass = 'bg-slate-100 dark:bg-slate-800/80 text-slate-600 dark:text-slate-400';
let label = '未知';
let icon = <Clock className="h-4 w-4"/>;
if (status === 'up') {
containerClass = 'bg-emerald-50 text-emerald-700';
containerClass = 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-200';
label = '正常';
icon = <CheckCircle2 className="h-4 w-4"/>;
} else if (status === 'down') {
containerClass = 'bg-red-50 text-red-700';
containerClass = 'bg-red-50 dark:bg-rose-500/15 text-red-700 dark:text-rose-200';
label = '异常';
icon = <AlertCircle className="h-4 w-4"/>;
}
@@ -133,13 +133,13 @@ const UptimeBar = ({uptime}: { uptime: number }) => {
return (
<div className="flex items-center gap-2">
<div className="relative h-3 w-full overflow-hidden rounded-lg bg-slate-100">
<div className="relative h-3 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-700">
<div
className={`absolute inset-y-0 left-0 ${colorClass} transition-all duration-500`}
style={{width: `${percentage}%`}}
/>
</div>
<span className="text-sm font-semibold text-slate-700 w-16 text-right">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300 w-16 text-right">
{formatPercentValue(percentage)}%
</span>
</div>
@@ -153,21 +153,21 @@ const StatCard = ({icon, label, value, color = 'blue'}: {
color?: string;
}) => {
const colorClasses = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-emerald-50 text-emerald-600',
yellow: 'bg-yellow-50 text-yellow-600',
red: 'bg-red-50 text-red-600',
blue: 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400',
green: 'bg-emerald-50 dark:bg-emerald-950 text-emerald-600 dark:text-emerald-400',
yellow: 'bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400',
red: 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400',
};
return (
<div className="rounded-2xl border border-slate-100 bg-white/95 p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60 p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg dark:hover:shadow-slate-950/70">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
{icon}
</div>
<div>
<div className="text-sm text-slate-600">{label}</div>
<div className="mt-1 text-2xl font-bold text-slate-900">{value}</div>
<div className="text-sm text-slate-600 dark:text-slate-400">{label}</div>
<div className="mt-1 text-2xl font-bold text-slate-900 dark:text-slate-100">{value}</div>
</div>
</div>
</div>
@@ -180,8 +180,8 @@ const CustomTooltip = ({active, payload, label, unit = ' ms'}: TooltipProps<numb
}
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs shadow-lg">
<p className="font-semibold text-slate-700">{label}</p>
<div className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-3 py-2 text-xs shadow-lg dark:shadow-slate-950/50">
<p className="font-semibold text-slate-700 dark:text-slate-200">{label}</p>
<div className="mt-1 space-y-1">
{payload.map((entry, index) => {
if (!entry) {
@@ -198,7 +198,7 @@ const CustomTooltip = ({active, payload, label, unit = ' ms'}: TooltipProps<numb
: entry.value;
return (
<p key={`${entry.dataKey ?? index}`} className="flex items-center gap-2 text-slate-600">
<p key={`${entry.dataKey ?? index}`} className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
<span
className="inline-block h-2 w-2 rounded-full"
style={{backgroundColor: dotColor}}
@@ -233,8 +233,8 @@ const TimeRangeSelector = ({
onClick={() => onChange(option.value)}
className={`rounded-lg border px-3 py-1.5 text-sm transition ${
isActive
? 'border-blue-200 bg-blue-600 text-white'
: 'border-slate-200 bg-white text-slate-500 hover:border-blue-200 hover:text-blue-600'
? 'border-sky-200 dark:border-sky-400 bg-sky-600 dark:bg-sky-500 text-white'
: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:border-sky-200 dark:hover:border-sky-500 hover:text-sky-600 dark:hover:text-sky-200'
}`}
>
{option.label}
@@ -363,11 +363,11 @@ const MonitorDetail = () => {
];
return (
<div className="bg-slate-50">
<div className="bg-slate-50 dark:bg-slate-900">
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-6 sm:px-6 lg:px-8">
{/* Hero Section */}
<section className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-slate-800 to-blue-900 p-6 text-white shadow-xl">
<div className="absolute inset-0 opacity-30 [background-image:radial-gradient(circle_at_top,rgba(255,255,255,0.35),transparent_55%)]"/>
<section className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-slate-800 to-blue-900 dark:from-slate-950 dark:via-slate-900 dark:to-blue-950 p-6 text-white shadow-xl">
<div className="absolute inset-0 opacity-30 dark:opacity-20 [background-image:radial-gradient(circle_at_top,rgba(255,255,255,0.35),transparent_55%)]"/>
<div className="relative flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
@@ -448,20 +448,20 @@ const MonitorDetail = () => {
{hasCert && (
<div className={`mt-6 rounded-2xl border p-6 ${
certExpiringSoon
? 'border-yellow-200 bg-yellow-50'
: 'border-slate-200 bg-white'
? 'border-amber-200 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10'
: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60'
}`}>
<div className="flex items-center gap-3">
<Shield className={`h-6 w-6 ${certExpiringSoon ? 'text-yellow-600' : 'text-slate-600'}`}/>
<Shield className={`h-6 w-6 ${certExpiringSoon ? 'text-amber-600 dark:text-amber-200' : 'text-slate-600 dark:text-slate-400'}`}/>
<div>
<h3 className={`text-lg font-semibold ${certExpiringSoon ? 'text-yellow-900' : 'text-slate-900'}`}>
<h3 className={`text-lg font-semibold ${certExpiringSoon ? 'text-amber-900 dark:text-amber-100' : 'text-slate-900 dark:text-slate-50'}`}>
TLS 证书信息
</h3>
<p className={`mt-1 text-sm ${certExpiringSoon ? 'text-yellow-700' : 'text-slate-600'}`}>
<p className={`mt-1 text-sm ${certExpiringSoon ? 'text-amber-700 dark:text-amber-200' : 'text-slate-600 dark:text-slate-400'}`}>
证书到期时间: {formatDate(firstStat.certExpiryDate)} (剩余 {firstStat.certExpiryDays} 天)
</p>
{certExpiringSoon && (
<p className="mt-2 text-sm font-medium text-yellow-700">
<p className="mt-2 text-sm font-medium text-amber-700 dark:text-amber-200">
⚠️ 证书即将过期,请及时更新
</p>
)}
@@ -482,7 +482,7 @@ const MonitorDetail = () => {
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 hover:border-blue-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:border-sky-300 dark:hover:border-sky-500 focus:border-sky-500 dark:focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:ring-sky-500/40"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
@@ -510,14 +510,16 @@ const MonitorDetail = () => {
);
})}
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-700"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} ms`}
/>
@@ -547,11 +549,11 @@ const MonitorDetail = () => {
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-slate-200 text-sm text-slate-500">
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-800 text-sm text-slate-500 dark:text-slate-300">
<div className="text-center">
<TrendingUp className="mx-auto h-12 w-12 text-slate-300 mb-3"/>
<TrendingUp className="mx-auto h-12 w-12 text-slate-300 dark:text-slate-600 mb-3"/>
<p>正在收集数据,请稍候...</p>
<p className="text-xs text-slate-400 mt-1">图表将在数据采集后显示</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">图表将在数据采集后显示</p>
</div>
</div>
)}
@@ -561,36 +563,36 @@ const MonitorDetail = () => {
<Card title="探针监控详情" description="各探针的当前状态和统计数据">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-slate-200 bg-slate-50">
<thead className="border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/70">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
探针 ID
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
状态
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
当前响应
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
24h 在线率
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
30d 在线率
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
24h 检测
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700">
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">
最后检测
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{monitorStats.map((stats, index) => {
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<tr key={stats.id} className="transition-colors hover:bg-slate-50">
<tr key={stats.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span
@@ -600,15 +602,15 @@ const MonitorDetail = () => {
<div className="flex flex-col">
{stats.agentName ? (
<>
<span className="text-sm font-medium text-slate-900">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{stats.agentName}
</span>
<span className="font-mono text-xs text-slate-500">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{stats.agentId.substring(0, 8)}...
</span>
</>
) : (
<span className="font-mono text-sm text-slate-700">
<span className="font-mono text-sm text-slate-700 dark:text-slate-300">
{stats.agentId.substring(0, 8)}...
</span>
)}
@@ -620,8 +622,8 @@ const MonitorDetail = () => {
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-400"/>
<span className="text-sm font-medium text-slate-900">
<Clock className="h-4 w-4 text-slate-400 dark:text-slate-500"/>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{formatTime(stats.currentResponse)}
</span>
</div>
@@ -637,12 +639,12 @@ const MonitorDetail = () => {
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-slate-700">
<div className="text-sm text-slate-700 dark:text-slate-300">
{stats.successChecks24h} / {stats.totalChecks24h}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-slate-700">
<div className="text-sm text-slate-700 dark:text-slate-300">
{formatDateTime(stats.lastCheckTime)}
</div>
</td>
+45 -45
View File
@@ -28,7 +28,7 @@ const formatPercentValue = (value: number): string => (Number.isFinite(value) ?
const LoadingSpinner = () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-slate-600">
<div className="flex flex-col items-center gap-3 text-slate-600 dark:text-slate-400">
<Loader2 className="h-8 w-8 animate-spin"/>
<span className="text-sm">加载监控数据中...</span>
</div>
@@ -36,16 +36,16 @@ const LoadingSpinner = () => (
);
const StatusBadge = ({status}: { status: string }) => {
let containerClass = 'bg-slate-100 text-slate-600';
let containerClass = 'bg-slate-100 dark:bg-slate-900/60 text-slate-600 dark:text-slate-300';
let label = '未知';
let icon = <Clock className="h-3.5 w-3.5"/>;
if (status === 'up') {
containerClass = 'bg-emerald-50 text-emerald-700';
containerClass = 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
label = '正常';
icon = <CheckCircle2 className="h-3.5 w-3.5"/>;
} else if (status === 'down') {
containerClass = 'bg-red-50 text-red-700';
containerClass = 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300';
label = '异常';
icon = <AlertCircle className="h-3.5 w-3.5"/>;
}
@@ -64,7 +64,7 @@ const UptimeBar = ({uptime}: { uptime: number }) => {
const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100">
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-900/60">
<div
className={`absolute inset-y-0 left-0 ${colorClass} transition-all duration-500`}
style={{width: `${percentage}%`}}
@@ -74,7 +74,7 @@ const UptimeBar = ({uptime}: { uptime: number }) => {
};
const EmptyState = () => (
<div className="flex min-h-[400px] flex-col items-center justify-center text-slate-500">
<div className="flex min-h-[400px] flex-col items-center justify-center text-slate-500 dark:text-slate-400">
<Shield className="mb-4 h-16 w-16 opacity-20"/>
<p className="text-lg font-medium">暂无监控数据</p>
<p className="mt-2 text-sm">请先在管理后台添加监控任务</p>
@@ -120,21 +120,21 @@ const MonitorList = () => {
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
}
}}
className="group relative flex h-full cursor-pointer flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition duration-200 hover:border-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
className="group relative flex h-full cursor-pointer flex-col gap-4 rounded-2xl border border-slate-200 dark:border-slate-700 bg-transparent p-5 transition duration-200 hover:border-blue-300 dark:hover:border-blue-500/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-base font-semibold text-slate-900 truncate">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{stats.name}
</h3>
<StatusBadge status={stats.lastCheckStatus}/>
</div>
<p className="mt-1 text-sm text-slate-500 truncate" title={stats.showTargetPublic ? stats.target : '已隐藏'}>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 truncate" title={stats.showTargetPublic ? stats.target : '已隐藏'}>
{stats.showTargetPublic ? stats.target : '***'}
</p>
{stats.agentCount > 1 && (
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{stats.agentCount} 个探针
</p>
)}
@@ -143,66 +143,66 @@ const MonitorList = () => {
<div className="space-y-3">
<div
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-900/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Clock className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">当前响应</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">当前响应</span>
</div>
<span className="text-sm font-bold text-slate-900">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">
{formatTime(stats.currentResponse)}
</span>
</div>
<div
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-900/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Clock className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">24h 平均</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">24h 平均</span>
</div>
<span className="text-sm font-bold text-slate-900">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">
{formatTime(stats.avgResponse24h)}
</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-600">24h 在线率</span>
<span className="font-medium text-slate-600 dark:text-slate-300">24h 在线率</span>
<span
className="font-semibold text-slate-900">{formatPercentValue(stats.uptime24h)}%</span>
className="font-semibold text-slate-900 dark:text-slate-100">{formatPercentValue(stats.uptime24h)}%</span>
</div>
<UptimeBar uptime={stats.uptime24h}/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-600">30d 在线率</span>
<span className="font-medium text-slate-600 dark:text-slate-300">30d 在线率</span>
<span
className="font-semibold text-slate-900">{formatPercentValue(stats.uptime30d)}%</span>
className="font-semibold text-slate-900 dark:text-slate-100">{formatPercentValue(stats.uptime30d)}%</span>
</div>
<UptimeBar uptime={stats.uptime30d}/>
</div>
{hasCert && (
<div
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-900/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<Shield
className={`h-4 w-4 ${certExpiringSoon ? 'text-yellow-600' : 'text-blue-600'}`}/>
<span className="text-xs font-medium text-slate-600">证书到期</span>
className={`h-4 w-4 ${certExpiringSoon ? 'text-yellow-600 dark:text-yellow-500' : 'text-blue-600 dark:text-blue-400'}`}/>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">证书到期</span>
</div>
<div className="text-right">
<div
className={`text-xs font-medium ${certExpiringSoon ? 'text-yellow-700' : 'text-slate-700'}`}>
className={`text-xs font-medium ${certExpiringSoon ? 'text-yellow-700 dark:text-yellow-300' : 'text-slate-700 dark:text-slate-200'}`}>
{formatDate(stats.certExpiryDate)}
</div>
<div
className={`text-xs ${certExpiringSoon ? 'text-yellow-600' : 'text-slate-500'}`}>
className={`text-xs ${certExpiringSoon ? 'text-yellow-600 dark:text-yellow-400' : 'text-slate-500 dark:text-slate-400'}`}>
剩余 {stats.certExpiryDays} 天
</div>
</div>
@@ -218,10 +218,10 @@ const MonitorList = () => {
const renderListView = () => (
<>
{/* 桌面端:使用表格布局 */}
<div className="hidden overflow-hidden rounded-md border border-slate-200 bg-white lg:block">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-blue-50">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-blue-600">
<div className="hidden overflow-hidden rounded-md border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 lg:block">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-600 text-sm">
<thead className="bg-blue-50 dark:bg-blue-900/30">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
<th className="px-5 py-3">监控项</th>
<th className="px-5 py-3">状态</th>
<th className="px-5 py-3">当前响应</th>
@@ -231,7 +231,7 @@ const MonitorList = () => {
<th className="px-5 py-3">证书信息</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 text-slate-700">
<tbody className="divide-y divide-slate-200 dark:divide-slate-600 text-slate-700 dark:text-slate-200">
{monitorSummaries.map((stats) => {
const hasCert = stats.certExpiryDate > 0;
const certExpiringSoon = hasCert && stats.certExpiryDays < 30;
@@ -247,18 +247,18 @@ const MonitorList = () => {
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
}
}}
className="cursor-pointer transition hover:bg-blue-50 focus-within:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
className="cursor-pointer transition hover:bg-blue-50 dark:hover:bg-slate-700 focus-within:bg-blue-50 dark:focus-within:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
>
<td className="px-5 py-4 align-center">
<div>
<div className="font-semibold text-slate-900">
<div className="font-semibold text-slate-900 dark:text-slate-100">
{stats.name}
</div>
<div className="mt-1 text-xs text-slate-500 break-all">
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 break-all">
{stats.showTargetPublic ? stats.target : '***'}
</div>
{stats.agentCount > 1 && (
<div className="mt-1 text-xs text-slate-400">
<div className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{stats.agentCount} 个探针
</div>
)}
@@ -269,14 +269,14 @@ const MonitorList = () => {
</td>
<td className="px-5 py-4 align-center">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-400"/>
<span className="text-sm font-semibold text-slate-900">
<Clock className="h-4 w-4 text-slate-400 dark:text-slate-500"/>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{formatTime(stats.currentResponse)}
</span>
</div>
</td>
<td className="px-5 py-4 align-center">
<span className="text-sm font-medium text-slate-700">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{formatTime(stats.avgResponse24h)}
</span>
</td>
@@ -286,7 +286,7 @@ const MonitorList = () => {
<div className="w-24">
<UptimeBar uptime={stats.uptime24h}/>
</div>
<span className="text-xs font-semibold text-slate-900 w-14 text-right">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-100 w-14 text-right">
{formatPercentValue(stats.uptime24h)}%
</span>
</div>
@@ -298,7 +298,7 @@ const MonitorList = () => {
<div className="w-24">
<UptimeBar uptime={stats.uptime30d}/>
</div>
<span className="text-xs font-semibold text-slate-900 w-14 text-right">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-100 w-14 text-right">
{formatPercentValue(stats.uptime30d)}%
</span>
</div>
@@ -308,20 +308,20 @@ const MonitorList = () => {
{hasCert ? (
<div className="flex items-center gap-2">
<Shield
className={`h-4 w-4 ${certExpiringSoon ? 'text-yellow-600' : 'text-slate-400'}`}/>
className={`h-4 w-4 ${certExpiringSoon ? 'text-yellow-600 dark:text-yellow-500' : 'text-slate-400 dark:text-slate-500'}`}/>
<div className="text-xs">
<div
className={certExpiringSoon ? 'font-medium text-yellow-700' : 'font-medium text-slate-700'}>
className={certExpiringSoon ? 'font-medium text-yellow-700 dark:text-yellow-300' : 'font-medium text-slate-700 dark:text-slate-200'}>
{formatDate(stats.certExpiryDate)}
</div>
<div
className={`${certExpiringSoon ? 'text-yellow-600' : 'text-slate-500'}`}>
className={`${certExpiringSoon ? 'text-yellow-600 dark:text-yellow-400' : 'text-slate-500 dark:text-slate-400'}`}>
剩余 {stats.certExpiryDays} 天
</div>
</div>
</div>
) : (
<span className="text-sm text-slate-400">-</span>
<span className="text-sm text-slate-400 dark:text-slate-500">-</span>
)}
</td>
</tr>
+117 -104
View File
@@ -106,21 +106,21 @@ const INTERFACE_COLORS = [
];
const LoadingSpinner = () => (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-slate-400"/>
<p className="text-sm text-slate-500">数据加载中,请稍候...</p>
<Loader2 className="h-8 w-8 animate-spin text-slate-400 dark:text-slate-400"/>
<p className="text-sm text-slate-500 dark:text-slate-400">数据加载中,请稍候...</p>
</div>
</div>
);
const EmptyState = ({message = '服务器不存在或已离线'}: { message?: string }) => (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-900 text-slate-400 dark:text-slate-300">
<Server className="h-8 w-8"/>
</div>
<p className="text-sm text-slate-500">{message}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{message}</p>
</div>
</div>
);
@@ -136,13 +136,13 @@ const Card = ({
action?: ReactNode;
children: ReactNode;
}) => (
<section className="rounded-3xl border border-slate-100 bg-white/95 p-6 shadow-sm">
<section className="rounded-3xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60 p-6 shadow-sm">
{(title || description || action) && (
<div
className="flex flex-col gap-3 border-b border-slate-100 pb-4 sm:flex-row sm:items-start sm:justify-between">
className="flex flex-col gap-3 border-b border-slate-100 dark:border-slate-800 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div>
{title ? <h2 className="text-lg font-semibold text-slate-900">{title}</h2> : null}
{description ? <p className="mt-1 text-sm text-slate-500">{description}</p> : null}
{title ? <h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">{title}</h2> : null}
{description ? <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{description}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
@@ -155,29 +155,29 @@ type AccentVariant = 'blue' | 'emerald' | 'purple' | 'sky' | 'amber';
const accentThemes: Record<AccentVariant, { icon: string; badge: string; highlight: string }> = {
blue: {
icon: 'bg-blue-50 text-blue-600',
badge: 'bg-blue-100 text-blue-600',
highlight: 'text-blue-600',
icon: 'bg-blue-50 dark:bg-sky-900/30 text-blue-600 dark:text-sky-200',
badge: 'bg-blue-100 dark:bg-sky-500/15 text-blue-600 dark:text-sky-200',
highlight: 'text-blue-600 dark:text-sky-200',
},
emerald: {
icon: 'bg-emerald-50 text-emerald-600',
badge: 'bg-emerald-100 text-emerald-600',
highlight: 'text-emerald-600',
icon: 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-200',
badge: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-200',
highlight: 'text-emerald-600 dark:text-emerald-200',
},
purple: {
icon: 'bg-purple-50 text-purple-600',
badge: 'bg-purple-100 text-purple-600',
highlight: 'text-purple-600',
icon: 'bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-200',
badge: 'bg-purple-100 dark:bg-purple-500/15 text-purple-600 dark:text-purple-200',
highlight: 'text-purple-600 dark:text-purple-200',
},
sky: {
icon: 'bg-sky-50 text-sky-600',
badge: 'bg-sky-100 text-sky-600',
highlight: 'text-sky-600',
icon: 'bg-sky-50 dark:bg-sky-900/30 text-sky-600 dark:text-sky-200',
badge: 'bg-sky-100 dark:bg-sky-500/15 text-sky-600 dark:text-sky-200',
highlight: 'text-sky-600 dark:text-sky-200',
},
amber: {
icon: 'bg-amber-50 text-amber-600',
badge: 'bg-amber-100 text-amber-600',
highlight: 'text-amber-600',
icon: 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-200',
badge: 'bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-200',
highlight: 'text-amber-600 dark:text-amber-200',
},
};
@@ -185,8 +185,8 @@ const InfoGrid = ({items}: { items: Array<{ label: string; value: ReactNode }> }
<dl className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{items.map((item) => (
<div key={item.label}>
<dt className="text-xs font-medium text-slate-500">{item.label}</dt>
<dd className="mt-1 font-medium text-slate-900">{item.value}</dd>
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">{item.label}</dt>
<dd className="mt-1 font-medium text-slate-900 dark:text-slate-100">{item.value}</dd>
</div>
))}
</dl>
@@ -209,8 +209,8 @@ const TimeRangeSelector = ({
onClick={() => onChange(option.value)}
className={`rounded-lg border px-3 py-1.5 text-sm transition ${
isActive
? 'border-blue-200 bg-blue-600 text-white'
: 'border-slate-200 bg-white text-slate-500 hover:border-blue-200 hover:text-blue-600'
? 'border-sky-200 dark:border-sky-400 bg-sky-600 dark:bg-sky-500 text-white'
: 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:border-sky-200 dark:hover:border-sky-500 hover:text-sky-600 dark:hover:text-sky-200'
}`}
>
{option.label}
@@ -404,22 +404,22 @@ const SnapshotGrid = ({cards}: { cards: SnapshotCardData[] }) => (
return (
<div
key={card.key}
className="rounded-2xl border border-slate-100 bg-white/95 p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"
className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60 p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg dark:hover:shadow-slate-950/70"
>
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2">
<span className={`flex h-9 w-9 items-center justify-center rounded-lg ${theme.icon}`}>
<card.icon className="h-4 w-4"/>
</span>
<p className="text-sm font-semibold text-slate-900">{card.title}</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-50">{card.title}</p>
</div>
<span className={`text-xl font-bold ${theme.highlight}`}>{card.usagePercent}</span>
</div>
<div className="space-y-2">
{card.metrics.map((metric) => (
<div key={metric.label} className="flex items-center justify-between text-xs">
<span className="text-slate-500">{metric.label}</span>
<span className="ml-2 text-right font-medium text-slate-900">{metric.value}</span>
<span className="text-slate-500 dark:text-slate-400">{metric.label}</span>
<span className="ml-2 text-right font-medium text-slate-900 dark:text-slate-50">{metric.value}</span>
</div>
))}
</div>
@@ -436,8 +436,8 @@ const SnapshotSection = ({cards}: { cards: SnapshotCardData[] }) => {
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-700">资源快照</h3>
<p className="mt-1 text-xs text-slate-500">最近 5 秒采集的资源使用状况</p>
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">资源快照</h3>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">最近 5 秒采集的资源使用状况</p>
</div>
<SnapshotGrid cards={cards}/>
</div>
@@ -450,8 +450,8 @@ const CustomTooltip = ({active, payload, label, unit = '%'}: MetricsTooltipProps
}
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs">
<p className="font-semibold text-slate-700">{label}</p>
<div className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-3 py-2 text-xs shadow-sm dark:shadow-slate-950/50">
<p className="font-semibold text-slate-700 dark:text-slate-200">{label}</p>
<div className="mt-1 space-y-1">
{payload.map((entry, index) => {
if (!entry) {
@@ -468,7 +468,7 @@ const CustomTooltip = ({active, payload, label, unit = '%'}: MetricsTooltipProps
: entry.value;
return (
<p key={`${entry.dataKey ?? index}`} className="flex items-center gap-2 text-slate-600">
<p key={`${entry.dataKey ?? index}`} className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span
className="inline-block h-2 w-2 rounded-full"
style={{backgroundColor: dotColor}}
@@ -753,7 +753,7 @@ const ServerDetail = () => {
const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-';
const displayName = agent?.name?.trim() ? agent.name : '未命名探针';
const isOnline = agent?.status === 1;
const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-slate-400';
const statusDotStyles = isOnline ? 'bg-emerald-500 dark:bg-emerald-400' : 'bg-slate-400 dark:bg-slate-500';
const statusText = isOnline ? '在线' : '离线';
const networkSummary = latestMetrics?.network
@@ -795,7 +795,7 @@ const ServerDetail = () => {
}
return (
<div className="bg-slate-50">
<div className="bg-slate-50 dark:bg-slate-900">
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-6 sm:px-6 lg:px-8">
<section
className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-slate-800 to-blue-900 p-6 text-white shadow-xl">
@@ -807,19 +807,19 @@ const ServerDetail = () => {
<button
type="button"
onClick={() => navigate('/')}
className="group inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-white/70 transition hover:text-white"
className="group inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-white/70 dark:text-white/70 transition hover:text-white dark:hover:text-white"
>
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-0.5"/>
返回概览
</button>
<div className="flex items-start gap-4">
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 text-white">
className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 dark:bg-white/10 text-white">
<Server className="h-7 w-7"/>
</div>
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-semibold">{displayName}</h1>
<h1 className="text-3xl font-semibold text-white">{displayName}</h1>
<span
className={`inline-flex items-center gap-1 rounded-full px-3 py-0.5 text-xs font-medium ${
isOnline
@@ -843,10 +843,10 @@ const ServerDetail = () => {
{heroStats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl bg-white/10 p-4 text-left backdrop-blur"
className="rounded-2xl bg-white/10 dark:bg-white/10 p-4 text-left backdrop-blur"
>
<p className="text-[11px] uppercase tracking-[0.3em] text-white/70">{stat.label}</p>
<p className="mt-2 text-base font-semibold">{stat.value}</p>
<p className="mt-2 text-base font-semibold text-white">{stat.value}</p>
</div>
))}
</div>
@@ -865,16 +865,16 @@ const ServerDetail = () => {
<Card title="系统信息" description="探针基础属性、运行状态与资源概览">
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-slate-100 bg-slate-50/60 p-4">
<h3 className="text-sm font-semibold text-slate-700">运行环境</h3>
<p className="mt-1 text-xs text-slate-500">来自最近一次探针上报的硬件与系统信息</p>
<div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-950/40 p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">运行环境</h3>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">来自最近一次探针上报的硬件与系统信息</p>
<div className="mt-4">
<InfoGrid items={environmentInfo}/>
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-slate-50/60 p-4">
<h3 className="text-sm font-semibold text-slate-700">运行状态</h3>
<p className="mt-1 text-xs text-slate-500">关键时间与网络指标,帮助快速判断主机健康状况</p>
<div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-950/40 p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">运行状态</h3>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">关键时间与网络指标,帮助快速判断主机健康状况</p>
<div className="mt-4">
<InfoGrid items={statusInfo}/>
</div>
@@ -891,9 +891,9 @@ const ServerDetail = () => {
>
<div className="grid gap-6 md:grid-cols-2">
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Cpu className="h-4 w-4"/>
</span>
CPU 使用率
@@ -907,15 +907,17 @@ const ServerDetail = () => {
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
@@ -933,16 +935,16 @@ const ServerDetail = () => {
</ResponsiveContainer>
) : (
<div
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 text-sm text-slate-500">
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-800 text-sm text-slate-500 dark:text-slate-400">
暂无数据
</div>
)}
</section>
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400">
<MemoryStick className="h-4 w-4"/>
</span>
内存使用率
@@ -956,15 +958,17 @@ const ServerDetail = () => {
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
@@ -982,7 +986,7 @@ const ServerDetail = () => {
</ResponsiveContainer>
) : (
<div
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 text-sm text-slate-500">
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-800 text-sm text-slate-500 dark:text-slate-400">
暂无数据
</div>
)}
@@ -990,9 +994,9 @@ const ServerDetail = () => {
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Network className="h-4 w-4"/>
</span>
网络流量(MB/s)
@@ -1001,7 +1005,7 @@ const ServerDetail = () => {
<select
value={selectedInterface}
onChange={(e) => setSelectedInterface(e.target.value)}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:border-blue-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-3 py-1.5 text-sm text-slate-700 dark:text-slate-200 hover:border-sky-300 dark:hover:border-sky-500 focus:border-sky-500 dark:focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:ring-sky-500/40"
>
<option value="all">所有网卡</option>
{availableInterfaces.map((iface) => (
@@ -1015,14 +1019,16 @@ const ServerDetail = () => {
{networkChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={networkChartData}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
@@ -1062,16 +1068,16 @@ const ServerDetail = () => {
</ResponsiveContainer>
) : (
<div
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 text-sm text-slate-500">
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-800 text-sm text-slate-500 dark:text-slate-400">
暂无数据
</div>
)}
</section>
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-100 text-rose-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-100 dark:bg-rose-900/40 text-rose-600 dark:text-rose-400">
<HardDrive className="h-4 w-4"/>
</span>
磁盘 I/O (MB/s)
@@ -1079,14 +1085,16 @@ const ServerDetail = () => {
{diskIOChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={diskIOChartData}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
@@ -1114,7 +1122,7 @@ const ServerDetail = () => {
</ResponsiveContainer>
) : (
<div
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 text-sm text-slate-500">
className="flex h-52 items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-800 text-sm text-slate-500 dark:text-slate-400">
暂无数据
</div>
)}
@@ -1122,31 +1130,34 @@ const ServerDetail = () => {
{gpuChartData.length > 0 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-400">
<Zap className="h-4 w-4"/>
</span>
GPU 使用率与温度
</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={gpuChartData}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
yAxisId="left"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
@@ -1179,9 +1190,9 @@ const ServerDetail = () => {
{temperatureChartData.length > 0 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg bg-orange-100 text-orange-600">
className="flex h-8 w-8 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-400">
<Thermometer className="h-4 w-4"/>
</span>
系统温度
@@ -1194,14 +1205,16 @@ const ServerDetail = () => {
<stop offset="95%" stopColor="#f97316" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="4 4"/>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-slate-200 dark:stroke-slate-600"/>
<XAxis
dataKey="time"
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="#94a3b8"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
@@ -1230,44 +1243,44 @@ const ServerDetail = () => {
{latestMetrics.gpu.map((gpu) => (
<div
key={gpu.index}
className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm"
className="rounded-2xl border border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-800 p-4 shadow-sm"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-600">
className="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 dark:bg-purple-900/40 text-purple-600 dark:text-purple-400">
<Zap className="h-4 w-4"/>
</span>
<div>
<p className="text-sm font-semibold text-slate-900">GPU {gpu.index}</p>
<p className="text-xs text-slate-500">{gpu.name}</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">GPU {gpu.index}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{gpu.name}</p>
</div>
</div>
<span className="text-2xl font-bold text-purple-600">
<span className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{gpu.utilization.toFixed(1)}%
</span>
</div>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-slate-500">温度</span>
<span className="text-slate-500 dark:text-slate-400">温度</span>
<span
className="font-medium text-slate-900">{gpu.temperature.toFixed(1)}°C</span>
className="font-medium text-slate-900 dark:text-slate-100">{gpu.temperature.toFixed(1)}°C</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">显存</span>
<span className="font-medium text-slate-900">
<span className="text-slate-500 dark:text-slate-400">显存</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">功耗</span>
<span className="text-slate-500 dark:text-slate-400">功耗</span>
<span
className="font-medium text-slate-900">{gpu.powerDraw.toFixed(1)}W</span>
className="font-medium text-slate-900 dark:text-slate-100">{gpu.powerDraw.toFixed(1)}W</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">风扇转速</span>
<span className="text-slate-500 dark:text-slate-400">风扇转速</span>
<span
className="font-medium text-slate-900">{gpu.fanSpeed.toFixed(0)}%</span>
className="font-medium text-slate-900 dark:text-slate-100">{gpu.fanSpeed.toFixed(0)}%</span>
</div>
</div>
</div>
@@ -1283,13 +1296,13 @@ const ServerDetail = () => {
{latestMetrics.temperature.map((temp) => (
<div
key={temp.sensorKey}
className="rounded-xl border border-slate-100 bg-gradient-to-br from-slate-50 to-white p-4"
className="rounded-xl border border-slate-100 dark:border-slate-800 bg-gradient-to-br from-slate-50 to-white dark:from-slate-700 dark:to-slate-800 p-4"
>
<div className="flex items-center gap-2 mb-2">
<Thermometer className="h-4 w-4 text-orange-500"/>
<p className="text-xs font-medium text-slate-600 truncate">{temp.sensorLabel}</p>
<Thermometer className="h-4 w-4 text-orange-500 dark:text-orange-400"/>
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 truncate">{temp.sensorLabel}</p>
</div>
<p className="text-2xl font-bold text-slate-900">{temp.temperature.toFixed(1)}°C</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{temp.temperature.toFixed(1)}°C</p>
</div>
))}
</div>
+83 -63
View File
@@ -39,7 +39,7 @@ const formatBytes = (bytes: number): string => {
const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(1) : '0.0');
const ProgressBar = ({percent, colorClass}: { percent: number; colorClass: string }) => (
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100">
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800">
<div
className={`absolute inset-y-0 left-0 ${colorClass} transition-all duration-500`}
style={{width: `${Math.min(Math.max(percent, 0), 100)}%`}}
@@ -48,10 +48,10 @@ const ProgressBar = ({percent, colorClass}: { percent: number; colorClass: strin
);
const LoadingSpinner = () => (
<div className="flex min-h-screen items-center justify-center bg-white">
<div className="flex min-h-screen items-center justify-center bg-white dark:bg-slate-950">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-slate-400"/>
<p className="text-sm text-slate-500">数据加载中,请稍候...</p>
<Loader2 className="h-8 w-8 animate-spin text-slate-400 dark:text-slate-400"/>
<p className="text-sm text-slate-500 dark:text-slate-400">数据加载中,请稍候...</p>
</div>
</div>
);
@@ -64,12 +64,12 @@ interface EmptyStateProps {
const EmptyState = ({title, description, extra}: EmptyStateProps) => (
<div
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-white p-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-slate-100 text-slate-500">
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900/60 p-12 text-center backdrop-blur">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-300">
<HardDrive className="h-7 w-7"/>
</div>
<h3 className="mt-4 text-base font-semibold text-slate-900">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-slate-500">{description}</p>
<h3 className="mt-4 text-base font-semibold text-slate-900 dark:text-slate-50">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>
{extra ? <div className="mt-4">{extra}</div> : null}
</div>
);
@@ -113,6 +113,24 @@ const getProgressColor = (percent: number) => {
return 'bg-emerald-500';
};
// 获取状态显示信息
const getStatusDisplay = (status: number) => {
if (status === 1) {
return {
text: '在线',
bgColor: 'bg-emerald-50 dark:bg-emerald-500/15',
textColor: 'text-emerald-700 dark:text-emerald-200',
dotColor: 'bg-emerald-500 dark:bg-emerald-400',
};
}
return {
text: '离线',
bgColor: 'bg-slate-50 dark:bg-slate-800/80',
textColor: 'text-slate-600 dark:text-slate-400',
dotColor: 'bg-slate-400 dark:bg-slate-500',
};
};
const ServerList = () => {
const navigate = useNavigate();
const {viewMode, setShowViewToggle} = usePublicLayout();
@@ -147,45 +165,46 @@ const ServerList = () => {
const diskUsage = calculateDiskUsage(agent.metrics);
const {upload, download} = calculateNetworkSpeed(agent.metrics);
const {totalUpload, totalDownload} = calculateNetworkTraffic(agent.metrics);
const statusDisplay = getStatusDisplay(agent.status);
return (
<Link
key={agent.id}
tabIndex={0}
to={`/servers/${agent.id}`}
className="group relative flex h-full cursor-pointer flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-5 transition duration-200 hover:border-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
className="group relative flex h-full cursor-pointer flex-col gap-5 rounded-2xl border border-slate-200 dark:border-slate-700 bg-transparent p-5 transition duration-200 hover:border-blue-300 dark:hover:border-blue-500/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"
>
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-slate-900">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-50">
{agent.name || agent.hostname}
</h3>
<span
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
<span className="flex h-1.5 w-1.5 rounded-lg bg-emerald-500"/>
在线
className={`inline-flex items-center gap-1.5 rounded-lg ${statusDisplay.bgColor} px-2.5 py-1 text-xs font-medium ${statusDisplay.textColor}`}>
<span className={`flex h-1.5 w-1.5 rounded-lg ${statusDisplay.dotColor}`}/>
{statusDisplay.text}
</span>
</div>
<span
className="inline-flex items-center gap-1 rounded-lg bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
className="inline-flex items-center gap-1 rounded-lg bg-blue-50 dark:bg-sky-500/10 px-2.5 py-1 text-xs font-medium text-blue-700 dark:text-sky-200">
{agent.os} · {agent.arch}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
{agent.tags && agent.tags.length > 0 && (
agent.tags?.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 rounded bg-blue-50 px-2 py-0.5 text-blue-700">
className="inline-flex items-center gap-1 rounded bg-blue-50 dark:bg-sky-500/10 px-2 py-0.5 text-blue-700 dark:text-sky-200">
{tag}
</span>
))
)}
{agent.expireTime > 0 && (
<span
className="inline-flex items-center gap-1 rounded bg-amber-50 px-2 py-0.5 text-amber-700">
className="inline-flex items-center gap-1 rounded bg-amber-50 dark:bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-200">
<span
className="font-medium">到期:</span> {new Date(agent.expireTime).toLocaleDateString('zh-CN')}
</span>
@@ -197,45 +216,45 @@ const ServerList = () => {
<div className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50/50 p-3">
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/60 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-sky-900/40 text-blue-600 dark:text-sky-200">
<Cpu className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">CPU</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">CPU</span>
</div>
<div className="text-sm font-bold text-slate-900">
<div className="text-sm font-bold text-slate-900 dark:text-slate-50">
{formatPercentValue(cpuUsage)}%
</div>
<ProgressBar percent={cpuUsage} colorClass={getProgressColor(cpuUsage)}/>
</div>
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50/50 p-3">
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/60 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-sky-900/40 text-blue-600 dark:text-sky-200">
<MemoryStick className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">内存</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">内存</span>
</div>
<div className="text-sm font-bold text-slate-900">
<div className="text-sm font-bold text-slate-900 dark:text-slate-50">
{formatPercentValue(memoryUsage)}%
</div>
<ProgressBar percent={memoryUsage} colorClass={getProgressColor(memoryUsage)}/>
</div>
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50/50 p-3">
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/60 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-sky-900/40 text-blue-600 dark:text-sky-200">
<HardDrive className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">磁盘</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">磁盘</span>
</div>
<div className="text-sm font-bold text-slate-900">
<div className="text-sm font-bold text-slate-900 dark:text-slate-50">
{formatPercentValue(diskUsage)}%
</div>
<ProgressBar percent={diskUsage} colorClass={getProgressColor(diskUsage)}/>
@@ -244,41 +263,41 @@ const ServerList = () => {
<div className="space-y-2">
<div
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-sky-900/40 text-blue-600 dark:text-sky-200">
<Network className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">实时速率</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">实时速率</span>
</div>
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700">
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700 dark:text-slate-100">
<span className="flex items-center gap-1">
<span className="text-slate-500">↑</span>
<span className="text-slate-500 dark:text-slate-400">↑</span>
{formatSpeed(upload)}
</span>
<span className="flex items-center gap-1">
<span className="text-slate-500">↓</span>
<span className="text-slate-500 dark:text-slate-400">↓</span>
{formatSpeed(download)}
</span>
</div>
</div>
<div
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100 dark:bg-sky-900/40 text-blue-600 dark:text-sky-200">
<EthernetPortIcon className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600">累计流量</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">累计流量</span>
</div>
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700">
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700 dark:text-slate-100">
<span className="flex items-center gap-1">
<span className="text-slate-500">↑</span>
<span className="text-slate-500 dark:text-slate-400">↑</span>
{formatTraffic(totalUpload)}
</span>
<span className="flex items-center gap-1">
<span className="text-slate-500">↓</span>
<span className="text-slate-500 dark:text-slate-400">↓</span>
{formatTraffic(totalDownload)}
</span>
</div>
@@ -294,10 +313,10 @@ const ServerList = () => {
const renderListView = () => (
<>
{/* 桌面端:使用表格布局 */}
<div className="hidden overflow-hidden rounded-md border border-slate-200 bg-white lg:block">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-blue-50">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-blue-600">
<div className="hidden overflow-hidden rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/60 lg:block">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800 text-sm">
<thead className="bg-blue-50 dark:bg-slate-900/70">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-sky-200">
<th className="px-5 py-3">服务器</th>
<th className="px-5 py-3">系统</th>
<th className="px-5 py-3">CPU</th>
@@ -306,7 +325,7 @@ const ServerList = () => {
<th className="px-5 py-3">网络</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 text-slate-700">
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 text-slate-700 dark:text-slate-100">
{filteredAgents.map((agent) => {
const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0;
const cpuModel = agent.metrics?.cpu?.modelName || '未知';
@@ -324,6 +343,7 @@ const ServerList = () => {
const diskFree = agent.metrics?.disk?.free ?? 0;
const {upload, download} = calculateNetworkSpeed(agent.metrics);
const statusDisplay = getStatusDisplay(agent.status);
return (
<tr
@@ -336,31 +356,31 @@ const ServerList = () => {
handleNavigate(agent.id);
}
}}
className="cursor-pointer transition hover:bg-blue-50 focus-within:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
className="cursor-pointer transition hover:bg-blue-50 dark:hover:bg-slate-900/70 focus-within:bg-blue-50 dark:focus-within:bg-slate-900/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"
>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-50">
{agent.name || agent.hostname}
</span>
<span
className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-600">
<span className="h-1.5 w-1.5 rounded-lg bg-emerald-500"/>
在线
className={`inline-flex items-center gap-1 rounded-lg ${statusDisplay.bgColor} px-2 py-0.5 text-xs font-medium ${statusDisplay.textColor}`}>
<span className={`h-1.5 w-1.5 rounded-lg ${statusDisplay.dotColor}`}/>
{statusDisplay.text}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
{agent.tags && agent.tags.length > 0 && (
agent.tags?.map((tag, index) => (
<span key={index} className="inline-flex items-center gap-1 text-blue-700">
<span key={index} className="inline-flex items-center gap-1 text-blue-700 dark:text-sky-200">
{tag}
</span>
))
)}
{agent.expireTime > 0 && (
<span className="inline-flex items-center gap-1 text-amber-700">
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-200">
<span
className="font-medium">到期:</span> {new Date(agent.expireTime).toLocaleDateString('zh-CN')}
</span>
@@ -368,9 +388,9 @@ const ServerList = () => {
</div>
</div>
</td>
<td className="px-5 py-4 align-center text-xs text-slate-500">
<td className="px-5 py-4 align-center text-xs text-slate-500 dark:text-slate-300">
<div>{agent.os}</div>
<div className="text-slate-400">{agent.arch}</div>
<div className="text-slate-400 dark:text-slate-400">{agent.arch}</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
@@ -379,11 +399,11 @@ const ServerList = () => {
<ProgressBar percent={cpuUsage}
colorClass={getProgressColor(cpuUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-50">
{formatPercentValue(cpuUsage)}%
</span>
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-slate-500 dark:text-slate-400">
<div className="truncate" style={{maxWidth: '200px'}}
title={cpuModel}>{cpuModel}</div>
<div>{cpuPhysicalCores}核{cpuLogicalCores}线程</div>
@@ -397,11 +417,11 @@ const ServerList = () => {
<ProgressBar percent={memoryUsage}
colorClass={getProgressColor(memoryUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-50">
{formatPercentValue(memoryUsage)}%
</span>
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-slate-500 dark:text-slate-400">
<div>总计:{formatBytes(memoryTotal)}</div>
<div>已用:{formatBytes(memoryUsed)} / 剩余:{formatBytes(memoryFree)}</div>
</div>
@@ -414,17 +434,17 @@ const ServerList = () => {
<ProgressBar percent={diskUsage}
colorClass={getProgressColor(diskUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-50">
{formatPercentValue(diskUsage)}%
</span>
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-slate-500 dark:text-slate-400">
<div>总计:{formatBytes(diskTotal)}</div>
<div>已用:{formatBytes(diskUsed)} / 剩余:{formatBytes(diskFree)}</div>
</div>
</div>
</td>
<td className="px-5 py-4 align-center text-xs text-slate-600">
<td className="px-5 py-4 align-center text-xs text-slate-600 dark:text-slate-300">
<div>↑ {formatSpeed(upload)}</div>
<div>↓ {formatSpeed(download)}</div>
</td>
+1 -1
View File
@@ -17,7 +17,7 @@ const PublicLayout = () => {
const [showViewToggle, setShowViewToggle] = useState(false);
return (
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
<div className="min-h-screen bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 flex flex-col transition-colors">
<PublicHeader
viewMode={viewMode}
onViewModeChange={setViewMode}
+35
View File
@@ -46,6 +46,8 @@ const AlertSettings = () => {
certThreshold: 30,
serviceEnabled: true,
serviceDuration: 300,
agentOfflineEnabled: true,
agentOfflineDuration: 300,
},
});
}
@@ -212,6 +214,39 @@ const AlertSettings = () => {
</Form.Item>
</Card>
<Card title="探针离线告警规则" type="inner">
<Form.Item noStyle shouldUpdate>
{({getFieldValue}) => {
const enabled = getFieldValue(['rules', 'agentOfflineEnabled']);
return (
<div className="flex items-center gap-8">
<Form.Item
label="开关"
name={['rules', 'agentOfflineEnabled']}
valuePropName="checked"
className="mb-0"
>
<Switch/>
</Form.Item>
<Form.Item
label="持续时间(秒)"
name={['rules', 'agentOfflineDuration']}
className="mb-0"
tooltip="探针持续离线多久后触发告警"
>
<InputNumber
min={1}
max={3600}
style={{width: '100%'}}
disabled={!enabled}
/>
</Form.Item>
</div>
);
}}
</Form.Item>
</Card>
<Button
type="primary"
icon={<Save size={16}/>}
+275 -20
View File
@@ -1,5 +1,5 @@
import {useEffect} from 'react';
import {App, Button, Card, Form, Input, Space, Spin, Switch} from 'antd';
import {App, Button, Card, Collapse, Form, Input, Select, Space, Spin, Switch} from 'antd';
import {TestTube} from 'lucide-react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import type {NotificationChannel} from '../../types';
@@ -64,6 +64,16 @@ const NotificationChannels = () => {
} else if (channel.type === 'webhook') {
formValues.webhookEnabled = channel.enabled;
formValues.webhookUrl = channel.config?.url || '';
formValues.webhookMethod = channel.config?.method || 'POST';
formValues.webhookBodyTemplate = channel.config?.bodyTemplate || 'json';
formValues.webhookCustomBody = channel.config?.customBody || '';
// 解析 headers 为数组形式方便编辑
const headers = channel.config?.headers || {};
formValues.webhookHeaders = Object.entries(headers).map(([key, value]) => ({
key,
value
}));
}
});
@@ -114,11 +124,25 @@ const NotificationChannels = () => {
// 自定义Webhook
if (values.webhookEnabled || values.webhookUrl) {
// 将 headers 数组转换为对象
const headersObj: Record<string, string> = {};
if (values.webhookHeaders && Array.isArray(values.webhookHeaders)) {
values.webhookHeaders.forEach((item: { key: string; value: string }) => {
if (item.key && item.value) {
headersObj[item.key] = item.value;
}
});
}
newChannels.push({
type: 'webhook',
enabled: values.webhookEnabled || false,
config: {
url: values.webhookUrl || '',
method: values.webhookMethod || 'POST',
bodyTemplate: values.webhookBodyTemplate || 'json',
customBody: values.webhookCustomBody || '',
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
},
});
}
@@ -349,38 +373,269 @@ const NotificationChannels = () => {
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.webhookEnabled !== currentValues.webhookEnabled
prevValues.webhookEnabled !== currentValues.webhookEnabled ||
prevValues.webhookBodyTemplate !== currentValues.webhookBodyTemplate
}
>
{({getFieldValue}) =>
getFieldValue('webhookEnabled') ? (
<Form.Item
label="Webhook URL"
name="webhookUrl"
rules={[
{required: true, message: '请输入自定义 Webhook URL'},
{type: 'url', message: '请输入有效的 URL'},
]}
tooltip="将发送完整的告警信息 JSON 到此地址"
>
<Input placeholder="https://your-server.com/webhook"/>
</Form.Item>
<>
<Form.Item
label="Webhook URL"
name="webhookUrl"
rules={[
{required: true, message: '请输入自定义 Webhook URL'},
{type: 'url', message: '请输入有效的 URL'},
]}
>
<Input placeholder="https://your-server.com/webhook"/>
</Form.Item>
<div className={'mb-4'}>
<Collapse
ghost
items={[
{
key: '1',
label: '高级配置',
children: (
<Space direction="vertical" className="w-full">
{/* HTTP 方法 */}
<Form.Item
label="HTTP 方法"
name="webhookMethod"
tooltip="选择 HTTP 请求方法"
>
<Select
placeholder="选择 HTTP 方法"
options={[
{label: 'GET', value: 'GET'},
{label: 'POST', value: 'POST'},
{label: 'PUT', value: 'PUT'},
{label: 'PATCH', value: 'PATCH'},
{label: 'DELETE', value: 'DELETE'},
]}
/>
</Form.Item>
{/* 请求体模板 */}
<Form.Item
label="请求体模板"
name="webhookBodyTemplate"
tooltip="选择请求体的格式"
>
<Select
placeholder="选择请求体模板"
options={[
{
label: 'JSON (默认)',
value: 'json'
},
{
label: 'Form 表单',
value: 'form'
},
{
label: '自定义模板',
value: 'custom'
},
]}
/>
</Form.Item>
{/* 自定义请求体 */}
{getFieldValue('webhookBodyTemplate') === 'custom' && (
<Form.Item
label="自定义请求体"
name="webhookCustomBody"
rules={[
{
required: true,
message: '请输入自定义请求体模板'
}
]}
tooltip="支持变量替换,可用变量见下方说明"
>
<Input.TextArea
rows={6}
placeholder='示例: {"alert": "{{alert.message}}", "host": "{{agent.hostname}}"}'
/>
</Form.Item>
)}
{/* 自定义请求头 */}
<Form.Item label="自定义请求头"
tooltip="添加自定义 HTTP 请求头">
<Form.List name="webhookHeaders">
{(fields, {add, remove}) => (
<>
{fields.map(({
key,
name,
...restField
}) => (
<Space
key={key}
style={{
display: 'flex',
marginBottom: 8
}}
align="baseline"
>
<Form.Item
{...restField}
name={[name, 'key']}
rules={[{
required: true,
message: '请输入 Header 名称'
}]}
>
<Input
placeholder="Header 名称"
style={{width: 200}}
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'value']}
rules={[{
required: true,
message: '请输入 Header 值'
}]}
>
<Input
placeholder="Header 值"
style={{width: 300}}
/>
</Form.Item>
<Button
onClick={() => remove(name)}
danger
type="link"
>
删除
</Button>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
>
添加请求头
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
</Space>
),
},
]}
/>
</div>
</>
) : null
}
</Form.Item>
<div className={'space-y-2'}>
<div>请求方式为 POST,消息格式如下</div>
<div>
<pre className={'border p-4 rounded-md'}>
<div className={'space-y-3 text-sm'}>
<div className={'font-semibold'}>请求体格式说明:</div>
{/* JSON 格式说明 */}
<div className={'space-y-1'}>
<strong>1. JSON 格式 (默认):</strong>
<div className={'text-gray-600 text-xs'}>
发送 <code className={'bg-gray-100 px-1 rounded'}>application/json</code> 格式的数据
</div>
<pre className={'border p-2 rounded-md text-xs mt-1 bg-gray-50'}>
{JSON.stringify({
"msg_type": "text",
"text": {
"content": "消息内容",
"text": {"content": "告警消息内容"},
"agent": {
"id": "agent-id",
"name": "探针名称",
"hostname": "主机名",
"ip": "192.168.1.1"
},
"alert": {
"type": "cpu",
"level": "warning",
"status": "firing",
"message": "CPU使用率过高",
"threshold": 80,
"actualValue": 85.5,
"firedAt": 1234567890000,
"resolvedAt": 0
}
}, null, 4)}
}, null, 2)}
</pre>
</div>
{/* Form 表单格式说明 */}
<div className={'space-y-1'}>
<strong>2. Form 表单格式:</strong>
<div className={'text-gray-600 text-xs'}>
发送 <code
className={'bg-gray-100 px-1 rounded'}>application/x-www-form-urlencoded</code> 格式的数据
</div>
<div className={'border p-2 rounded-md text-xs mt-1 bg-gray-50'}>
<div className={'font-semibold mb-1'}>包含以下字段:</div>
<div className={'grid grid-cols-2 gap-x-4 gap-y-1'}>
<div>• <code>message</code> - 告警消息</div>
<div>• <code>agent_id</code> - 探针ID</div>
<div>• <code>agent_name</code> - 探针名称</div>
<div>• <code>agent_hostname</code> - 主机名</div>
<div>• <code>agent_ip</code> - IP地址</div>
<div>• <code>alert_type</code> - 告警类型</div>
<div>• <code>alert_level</code> - 告警级别</div>
<div>• <code>alert_status</code> - 告警状态</div>
<div>• <code>alert_message</code> - 告警详情</div>
<div>• <code>threshold</code> - 阈值</div>
<div>• <code>actual_value</code> - 当前值</div>
<div>• <code>fired_at</code> - 触发时间戳</div>
<div>• <code>resolved_at</code> - 恢复时间戳</div>
</div>
</div>
</div>
{/* 自定义模板说明 */}
<div className={'space-y-1'}>
<strong>3. 自定义模板:</strong>
<div className={'text-gray-600 text-xs'}>
支持变量替换,Content-Type 为 <code
className={'bg-gray-100 px-1 rounded'}>text/plain</code>
</div>
<div className={'border p-2 rounded-md text-xs mt-1 bg-gray-50'}>
<div className={'font-semibold mb-1'}>可用变量:</div>
<div className={'grid grid-cols-2 gap-x-4 gap-y-1'}>
<div>• <code>{`{{message}}`}</code> - 告警消息</div>
<div>• <code>{`{{agent.id}}`}</code> - 探针ID</div>
<div>• <code>{`{{agent.name}}`}</code> - 探针名称</div>
<div>• <code>{`{{agent.hostname}}`}</code> - 主机名</div>
<div>• <code>{`{{agent.ip}}`}</code> - IP地址</div>
<div>• <code>{`{{alert.type}}`}</code> - 告警类型</div>
<div>• <code>{`{{alert.level}}`}</code> - 告警级别</div>
<div>• <code>{`{{alert.status}}`}</code> - 告警状态</div>
<div>• <code>{`{{alert.message}}`}</code> - 告警消息</div>
<div>• <code>{`{{alert.threshold}}`}</code> - 阈值</div>
<div>• <code>{`{{alert.actualValue}}`}</code> - 当前值</div>
<div>• <code>{`{{alert.firedAt}}`}</code> - 触发时间</div>
<div>• <code>{`{{alert.resolvedAt}}`}</code> - 恢复时间</div>
</div>
<div className={'mt-2 pt-2 border-t'}>
<div className={'font-semibold mb-1'}>示例:</div>
<pre className={'text-xs'}>
{`{
"alert": "{{alert.message}}",
"host": "{{agent.hostname}}",
"level": "{{alert.level}}"
}`}
</pre>
</div>
</div>
</div>
</div>
</Card>
+5 -3
View File
@@ -448,9 +448,11 @@ export interface FeishuConfig {
}
export interface WebhookConfig {
url: string; // 自定义URL
method?: string; // HTTP方法,默认POST
headers?: Record<string, string>; // 自定义请求头
url: string; // 自定义URL
method?: string; // HTTP方法:GET, POST, PUT, PATCH, DELETE,默认POST
headers?: Record<string, string>; // 自定义请求头
bodyTemplate?: 'json' | 'form' | 'custom'; // 请求体模板类型
customBody?: string; // 自定义请求体模板(当 bodyTemplate 为 custom 时使用)
}
export interface EmailConfig {