- 移除端口公网监听的渲染
- 增加探针离线/上线的监控 - 优化自定义 webhook - 增加暗黑模式
This commit is contained in:
1 parent
b5e188fa1d
commit
214158d022
23 files changed
+1177
-494
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
# 主流程
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}/>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user