- Agent 增加 info 命令,展示连接的服务器信息
- 优化页面
This commit is contained in:
1 parent
a9fc74e87d
commit
1f365604a5
17 files changed
+690
-377
No files matched your search
+64
-7
@@ -137,6 +137,14 @@ var registerCmd = &cobra.Command{
|
||||
Run: registerAgent,
|
||||
}
|
||||
|
||||
// infoCmd 信息命令
|
||||
var infoCmd = &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "显示配置信息",
|
||||
Long: `显示当前探针的配置信息`,
|
||||
Run: showInfo,
|
||||
}
|
||||
|
||||
var (
|
||||
serverEndpoint string
|
||||
serverAPIKey string
|
||||
@@ -155,6 +163,7 @@ func init() {
|
||||
// 添加子命令
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(registerCmd) // 注册命令放在前面,方便用户发现
|
||||
rootCmd.AddCommand(infoCmd)
|
||||
rootCmd.AddCommand(runCmd)
|
||||
rootCmd.AddCommand(installCmd)
|
||||
rootCmd.AddCommand(uninstallCmd)
|
||||
@@ -483,13 +492,7 @@ func registerAgent(cmd *cobra.Command, args []string) {
|
||||
Collector: config.CollectorConfig{
|
||||
Interval: 5,
|
||||
HeartbeatInterval: 30,
|
||||
NetworkExclude: []string{
|
||||
"^lo$",
|
||||
"^lo0$",
|
||||
"^docker.*",
|
||||
"^veth.*",
|
||||
"^br-.*",
|
||||
},
|
||||
NetworkExclude: config.DefaultNetworkExcludePatterns(),
|
||||
},
|
||||
AutoUpdate: config.AutoUpdateConfig{
|
||||
Enabled: true,
|
||||
@@ -543,3 +546,57 @@ func maskToken(token string) string {
|
||||
}
|
||||
return token[:4] + "****" + token[len(token)-4:]
|
||||
}
|
||||
|
||||
// showInfo 显示配置信息
|
||||
func showInfo(cmd *cobra.Command, args []string) {
|
||||
// 加载配置
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("═══════════════════════════════════════")
|
||||
fmt.Println(" 📋 Pika Agent 配置信息")
|
||||
fmt.Println("═══════════════════════════════════════")
|
||||
fmt.Println()
|
||||
|
||||
// 基本信息
|
||||
fmt.Println("🔧 基本配置:")
|
||||
fmt.Printf(" 配置文件路径: %s\n", configPath)
|
||||
fmt.Printf(" 探针名称: %s\n", cfg.Agent.Name)
|
||||
fmt.Printf(" 当前版本: %s\n", service.GetVersion())
|
||||
fmt.Println()
|
||||
|
||||
// 服务端信息
|
||||
fmt.Println("🌐 服务端配置:")
|
||||
fmt.Printf(" 服务端地址: %s\n", cfg.Server.Endpoint)
|
||||
fmt.Printf(" API Token: %s\n", maskToken(cfg.Server.APIKey))
|
||||
fmt.Println()
|
||||
|
||||
// 采集器配置
|
||||
fmt.Println("📊 采集器配置:")
|
||||
fmt.Printf(" 采集间隔: %d 秒\n", cfg.Collector.Interval)
|
||||
fmt.Printf(" 心跳间隔: %d 秒\n", cfg.Collector.HeartbeatInterval)
|
||||
if len(cfg.Collector.NetworkExclude) > 0 {
|
||||
fmt.Printf(" 网卡过滤规则: %v\n", cfg.Collector.NetworkExclude)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 自动更新配置
|
||||
fmt.Println("🔄 自动更新配置:")
|
||||
if cfg.AutoUpdate.Enabled {
|
||||
fmt.Printf(" 状态: 已启用\n")
|
||||
fmt.Printf(" 检查间隔: %s\n", cfg.AutoUpdate.CheckInterval)
|
||||
} else {
|
||||
fmt.Printf(" 状态: 已禁用\n")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 系统信息
|
||||
fmt.Println("💻 系统信息:")
|
||||
fmt.Printf(" 操作系统: %s\n", runtime.GOOS)
|
||||
fmt.Printf(" 系统架构: %s\n", runtime.GOARCH)
|
||||
hostname, _ := os.Hostname()
|
||||
fmt.Printf(" 主机名: %s\n", hostname)
|
||||
fmt.Println()
|
||||
}
|
||||
+2
-1
@@ -137,6 +137,7 @@ func setupApi(app *orz.App, components *AppComponents) {
|
||||
publicApi.GET("/agents/:id", components.AgentHandler.Get)
|
||||
publicApi.GET("/agents/:id/metrics", components.AgentHandler.GetMetrics)
|
||||
publicApi.GET("/agents/:id/metrics/latest", components.AgentHandler.GetLatestMetrics)
|
||||
publicApi.GET("/agents/:id/metrics/network-by-interface", components.AgentHandler.GetNetworkMetricsByInterface)
|
||||
|
||||
// Agent 版本和下载
|
||||
publicApi.GET("/agent/version", components.AgentHandler.GetAgentVersion)
|
||||
@@ -156,7 +157,7 @@ func setupApi(app *orz.App, components *AppComponents) {
|
||||
adminApi.Use(JWTAuthMiddleware(components.AccountHandler))
|
||||
{
|
||||
// 账户相关
|
||||
//adminApi.GET("/account/info", components.AccountHandler.Account)
|
||||
adminApi.GET("/account/info", components.AccountHandler.GetCurrentUser)
|
||||
adminApi.POST("/logout", components.AccountHandler.Logout)
|
||||
|
||||
// API密钥管理
|
||||
|
||||
@@ -138,3 +138,19 @@ func (r AccountHandler) Logout(c echo.Context) error {
|
||||
func (r AccountHandler) ValidateToken(tokenString string) (*service.JWTClaims, error) {
|
||||
return r.accountService.ValidateToken(tokenString)
|
||||
}
|
||||
|
||||
// GetCurrentUser 获取当前登录用户信息
|
||||
func (r AccountHandler) GetCurrentUser(c echo.Context) error {
|
||||
// 从 context 中获取用户信息(由 JWT 中间件设置)
|
||||
userID := c.Get("userID")
|
||||
username := c.Get("username")
|
||||
|
||||
if userID == nil || username == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "未登录")
|
||||
}
|
||||
|
||||
return orz.Ok(c, orz.Map{
|
||||
"userId": userID.(string),
|
||||
"username": username.(string),
|
||||
})
|
||||
}
|
||||
@@ -321,6 +321,54 @@ func (h *AgentHandler) GetMetrics(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetNetworkMetricsByInterface 获取按网卡接口分组的网络指标
|
||||
func (h *AgentHandler) GetNetworkMetricsByInterface(c echo.Context) error {
|
||||
agentID := c.Param("id")
|
||||
rangeParam := c.QueryParam("range")
|
||||
|
||||
// 根据 range 参数自动计算时间范围
|
||||
var start, end int64
|
||||
end = time.Now().UnixMilli()
|
||||
|
||||
if rangeParam == "" {
|
||||
rangeParam = "1h" // 默认1小时
|
||||
}
|
||||
|
||||
switch rangeParam {
|
||||
case "1m":
|
||||
start = end - 1*60*1000
|
||||
case "5m":
|
||||
start = end - 5*60*1000
|
||||
case "15m":
|
||||
start = end - 15*60*1000
|
||||
case "30m":
|
||||
start = end - 30*60*1000
|
||||
case "1h":
|
||||
start = end - 60*60*1000
|
||||
default:
|
||||
return orz.NewError(400, "无效的时间范围,支持: 1m, 5m, 15m, 30m, 1h")
|
||||
}
|
||||
|
||||
// 服务端自动计算最优聚合间隔
|
||||
interval := service.CalculateInterval(start, end)
|
||||
|
||||
ctx := c.Request().Context()
|
||||
metrics, err := h.agentService.GetNetworkMetricsByInterface(ctx, agentID, start, end, interval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return orz.Ok(c, orz.Map{
|
||||
"agentId": agentID,
|
||||
"type": "network_by_interface",
|
||||
"range": rangeParam,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"interval": interval,
|
||||
"metrics": metrics,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLatestMetrics 获取探针最新指标
|
||||
func (h *AgentHandler) GetLatestMetrics(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -264,6 +264,14 @@ type AggregatedNetworkMetric struct {
|
||||
MaxRecvRate float64 `json:"maxRecvRate"`
|
||||
}
|
||||
|
||||
// AggregatedNetworkMetricByInterface 按网卡接口分组的网络聚合指标
|
||||
type AggregatedNetworkMetricByInterface struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Interface string `json:"interface"`
|
||||
MaxSentRate float64 `json:"maxSentRate"`
|
||||
MaxRecvRate float64 `json:"maxRecvRate"`
|
||||
}
|
||||
|
||||
// GetNetworkMetrics 获取聚合后的网络指标(合并所有网卡接口)
|
||||
func (r *MetricRepo) GetNetworkMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedNetworkMetric, error) {
|
||||
var metrics []AggregatedNetworkMetric
|
||||
@@ -287,6 +295,30 @@ func (r *MetricRepo) GetNetworkMetrics(ctx context.Context, agentID string, star
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// GetNetworkMetricsByInterface 获取按网卡接口分组的网络指标
|
||||
func (r *MetricRepo) GetNetworkMetricsByInterface(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedNetworkMetricByInterface, error) {
|
||||
var metrics []AggregatedNetworkMetricByInterface
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
|
||||
interface,
|
||||
MAX(bytes_sent_rate) as max_sent_rate,
|
||||
MAX(bytes_recv_rate) as max_recv_rate
|
||||
FROM network_metrics
|
||||
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY 1, interface
|
||||
ORDER BY timestamp ASC, interface ASC
|
||||
`
|
||||
|
||||
intervalMs := int64(interval * 1000)
|
||||
err := r.db.WithContext(ctx).
|
||||
Raw(query, intervalMs, intervalMs, agentID, start, end).
|
||||
Scan(&metrics).Error
|
||||
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// AggregatedLoadMetric 负载聚合指标(使用最大值)
|
||||
type AggregatedLoadMetric struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
|
||||
@@ -54,7 +54,6 @@ type JWTClaims struct {
|
||||
// UserInfo 用户信息(简化版)
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
@@ -84,7 +83,6 @@ func (s *AccountService) Login(ctx context.Context, username, password string) (
|
||||
ExpiresAt: expiresAt,
|
||||
User: &UserInfo{
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -110,7 +108,6 @@ func (s *AccountService) LoginWithOIDC(ctx context.Context, code, state string)
|
||||
ExpiresAt: expiresAt,
|
||||
User: &UserInfo{
|
||||
Username: username,
|
||||
Nickname: nickname,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -249,7 +246,6 @@ func (s *AccountService) LoginWithGitHub(ctx context.Context, code, state string
|
||||
ExpiresAt: expiresAt,
|
||||
User: &UserInfo{
|
||||
Username: username,
|
||||
Nickname: nickname,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -416,6 +416,11 @@ func (s *AgentService) GetMetrics(ctx context.Context, agentID, metricType strin
|
||||
}
|
||||
}
|
||||
|
||||
// GetNetworkMetricsByInterface 获取按网卡接口分组的网络指标
|
||||
func (s *AgentService) GetNetworkMetricsByInterface(ctx context.Context, agentID string, start, end int64, interval int) (interface{}, error) {
|
||||
return s.metricRepo.GetNetworkMetricsByInterface(ctx, agentID, start, end, interval)
|
||||
}
|
||||
|
||||
// GetLatestMetrics 获取最新指标
|
||||
func (s *AgentService) GetLatestMetrics(ctx context.Context, agentID string) (*LatestMetrics, error) {
|
||||
result := &LatestMetrics{}
|
||||
|
||||
@@ -175,13 +175,14 @@ func (pc *ProcessChecker) CheckSuspiciousEnvVars() protocol.SecurityCheck {
|
||||
}
|
||||
value := parts[1]
|
||||
|
||||
// 检查是否是合法的库路径
|
||||
if pc.isLegitimateLibraryPath(value, name, exe) {
|
||||
// 改用风险特征检测,只在真正可疑时报警
|
||||
riskLevel, reason := pc.assessLibraryRiskWithReason(value, name, exe)
|
||||
|
||||
// 只报告中高风险的情况
|
||||
if riskLevel == "low" || riskLevel == "none" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 评估风险等级
|
||||
riskLevel := pc.assessLibraryRisk(value)
|
||||
status := StatusWarn
|
||||
if riskLevel == "high" {
|
||||
status = StatusFail
|
||||
@@ -195,7 +196,7 @@ func (pc *ProcessChecker) CheckSuspiciousEnvVars() protocol.SecurityCheck {
|
||||
check.Details = append(check.Details, protocol.SecurityCheckSub{
|
||||
Name: "suspicious_ld_env",
|
||||
Status: status,
|
||||
Message: fmt.Sprintf("进程 '%s'(PID: %d) 发现可疑环境变量: %s (风险等级: %s)", name, p.Pid, e, riskLevel),
|
||||
Message: fmt.Sprintf("进程 '%s'(PID: %d) 发现可疑环境变量: %s\n原因: %s", name, p.Pid, e, reason),
|
||||
Evidence: pc.evidence.CollectProcessEvidence(p, riskLevel),
|
||||
})
|
||||
break
|
||||
@@ -299,13 +300,35 @@ func (pc *ProcessChecker) isInDeletedWhitelist(name string) bool {
|
||||
|
||||
// isLegitimateLibraryPath 检查是否是合法的库路径
|
||||
func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe string) bool {
|
||||
pathLower := strings.ToLower(path)
|
||||
processNameLower := strings.ToLower(processName)
|
||||
processExeLower := strings.ToLower(processExe)
|
||||
|
||||
// 现代应用打包系统 (Snap, Flatpak, AppImage)
|
||||
// 这些系统会使用 LD_PRELOAD/LD_LIBRARY_PATH 加载隔离环境中的库
|
||||
appPackagingSystems := []string{
|
||||
"snap", "flatpak", "appimage",
|
||||
}
|
||||
for _, sys := range appPackagingSystems {
|
||||
// 检查路径是否来自这些打包系统
|
||||
if strings.Contains(pathLower, "/"+sys+"/") ||
|
||||
strings.Contains(pathLower, "."+sys+"/") {
|
||||
return true
|
||||
}
|
||||
// 检查进程是否由这些系统启动
|
||||
if strings.Contains(processExeLower, "/"+sys+"/") ||
|
||||
strings.Contains(processNameLower, sys) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 开发工具(IDE)
|
||||
devTools := []string{
|
||||
"jetbrains", "vscode", "idea", "pycharm", "goland", "webstorm",
|
||||
"clion", "rider", "datagrip", "androidstudio",
|
||||
}
|
||||
for _, tool := range devTools {
|
||||
if strings.Contains(strings.ToLower(processExe), tool) {
|
||||
if strings.Contains(processExeLower, tool) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -313,7 +336,7 @@ func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe
|
||||
// 数据库
|
||||
databases := []string{"postgres", "mysql", "mariadb", "mongodb", "redis", "elasticsearch", "clickhouse"}
|
||||
for _, db := range databases {
|
||||
if strings.Contains(strings.ToLower(processName), db) {
|
||||
if strings.Contains(processNameLower, db) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -321,7 +344,7 @@ func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe
|
||||
// 容器相关
|
||||
containers := []string{"docker", "containerd", "kubelet", "podman", "cri-o", "k3s", "kubernetes"}
|
||||
for _, container := range containers {
|
||||
if strings.Contains(strings.ToLower(processName), container) {
|
||||
if strings.Contains(processNameLower, container) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -329,7 +352,7 @@ func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe
|
||||
// Web 服务器
|
||||
webServers := []string{"nginx", "apache", "httpd", "caddy", "traefik"}
|
||||
for _, ws := range webServers {
|
||||
if strings.Contains(strings.ToLower(processName), ws) {
|
||||
if strings.Contains(processNameLower, ws) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -341,8 +364,8 @@ func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe
|
||||
"datadog", "newrelic", "prometheus", "grafana", // 监控工具
|
||||
}
|
||||
for _, tool := range scientificAndVirt {
|
||||
if strings.Contains(strings.ToLower(processName), tool) ||
|
||||
strings.Contains(strings.ToLower(processExe), tool) {
|
||||
if strings.Contains(processNameLower, tool) ||
|
||||
strings.Contains(processExeLower, tool) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -361,20 +384,130 @@ func (pc *ProcessChecker) isLegitimateLibraryPath(path, processName, processExe
|
||||
return false
|
||||
}
|
||||
|
||||
// assessLibraryRisk 评估库路径的风险等级
|
||||
func (pc *ProcessChecker) assessLibraryRisk(path string) string {
|
||||
// 高危路径
|
||||
highRiskPaths := []string{"/tmp/", "/dev/shm/", "/var/tmp/"}
|
||||
for _, hrp := range highRiskPaths {
|
||||
if strings.Contains(path, hrp) {
|
||||
return "high"
|
||||
// assessLibraryRiskWithReason 基于风险特征评估库路径的风险等级,返回风险等级和原因
|
||||
func (pc *ProcessChecker) assessLibraryRiskWithReason(path, processName, processExe string) (string, string) {
|
||||
pathLower := strings.ToLower(path)
|
||||
processNameLower := strings.ToLower(processName)
|
||||
processExeLower := strings.ToLower(processExe)
|
||||
|
||||
// 空路径
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "none", ""
|
||||
}
|
||||
|
||||
// 1. 首先排除明显安全的情况
|
||||
|
||||
// 系统标准库路径 - 非常安全
|
||||
systemLibPaths := []string{
|
||||
"/usr/lib/", "/lib/", "/usr/local/lib/",
|
||||
"/lib64/", "/usr/lib64/", "/usr/lib32/",
|
||||
}
|
||||
for _, sysPath := range systemLibPaths {
|
||||
if strings.HasPrefix(path, sysPath) {
|
||||
return "none", ""
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏文件
|
||||
if strings.Contains(path, "/.") {
|
||||
return "medium"
|
||||
// 已知的应用打包系统 - 安全
|
||||
appPackagingSystems := []string{
|
||||
"/snap/", "/var/lib/snapd/snap/",
|
||||
"/app/", "/var/lib/flatpak/",
|
||||
"/.local/share/flatpak/",
|
||||
}
|
||||
for _, sys := range appPackagingSystems {
|
||||
if strings.Contains(path, sys) {
|
||||
return "none", ""
|
||||
}
|
||||
}
|
||||
|
||||
return "low"
|
||||
// 常见的软件安装目录 - 相对安全
|
||||
commonAppPaths := []string{
|
||||
"/opt/", "/usr/share/", "/usr/local/share/",
|
||||
"/Applications/", // macOS
|
||||
}
|
||||
for _, appPath := range commonAppPaths {
|
||||
if strings.HasPrefix(path, appPath) {
|
||||
return "none", ""
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查高风险特征
|
||||
|
||||
// 临时目录中的库 - 高风险
|
||||
highRiskPaths := []string{"/tmp/", "/dev/shm/", "/var/tmp/"}
|
||||
for _, hrp := range highRiskPaths {
|
||||
if strings.Contains(pathLower, hrp) {
|
||||
return "high", fmt.Sprintf("库文件位于临时目录 %s,可能是恶意注入", hrp)
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏目录/文件 + 非标准路径 - 中风险
|
||||
if strings.Contains(path, "/.") {
|
||||
// 但如果是用户主目录下的隐藏配置目录,降低风险
|
||||
userConfigPaths := []string{
|
||||
"/.config/", "/.local/", "/.cache/",
|
||||
"/.wine/", "/.steam/", "/.mozilla/",
|
||||
}
|
||||
isUserConfig := false
|
||||
for _, userPath := range userConfigPaths {
|
||||
if strings.Contains(pathLower, userPath) {
|
||||
isUserConfig = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isUserConfig {
|
||||
return "medium", "库文件位于隐藏目录,且不是常见的用户配置目录"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查进程本身是否可疑
|
||||
|
||||
// 如果进程路径也在临时目录,风险升级
|
||||
if processExe != "" {
|
||||
for _, hrp := range highRiskPaths {
|
||||
if strings.Contains(strings.ToLower(processExe), hrp) {
|
||||
return "high", fmt.Sprintf("进程和库都位于临时目录,高度可疑")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查进程名是否可疑
|
||||
suspiciousNames := []string{
|
||||
"xmrig", "miner", "kthreaddk", "kthreaddi", ".sh",
|
||||
"python -c", "perl -e", "bash -i", "/dev/tcp",
|
||||
}
|
||||
for _, susName := range suspiciousNames {
|
||||
if strings.Contains(processNameLower, susName) || strings.Contains(processExeLower, susName) {
|
||||
return "high", fmt.Sprintf("进程名称或路径包含可疑特征: %s", susName)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 其他情况 - 低风险或无风险
|
||||
|
||||
// 用户主目录下的应用 - 低风险(很多用户自己编译或安装的软件)
|
||||
userHomePaths := []string{"/home/", "/root/", "/Users/"}
|
||||
for _, homePath := range userHomePaths {
|
||||
if strings.HasPrefix(path, homePath) {
|
||||
// 但如果在主目录的明显的程序目录,就是安全的
|
||||
safeUserPaths := []string{
|
||||
"/bin/", "/apps/", "/software/", "/opt/",
|
||||
"/go/", "/java/", "/python/", "/.jenv/", "/.pyenv/", "/.rbenv/",
|
||||
}
|
||||
for _, safePath := range safeUserPaths {
|
||||
if strings.Contains(pathLower, safePath) {
|
||||
return "none", ""
|
||||
}
|
||||
}
|
||||
// 其他情况,低风险,不报警
|
||||
return "low", ""
|
||||
}
|
||||
}
|
||||
|
||||
// 相对路径或当前目录 - 中等风险
|
||||
if strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") || !strings.HasPrefix(path, "/") {
|
||||
return "medium", "使用相对路径加载库,可能是本地开发或潜在风险"
|
||||
}
|
||||
|
||||
// 默认:不在标准路径但也没有明显风险特征 - 低风险,不报警
|
||||
return "low", ""
|
||||
}
|
||||
+82
-11
@@ -56,8 +56,14 @@ type CollectorConfig struct {
|
||||
// 心跳间隔(秒)
|
||||
HeartbeatInterval int `yaml:"heartbeat_interval"`
|
||||
|
||||
// 网络采集排除的网卡列表(支持正则表达式)
|
||||
// 如果为空,默认只排除回环地址(lo, lo0)
|
||||
// 网络采集包含的网卡列表(白名单,支持正则表达式)
|
||||
// 如果配置了此项,则只采集匹配的网卡,忽略 NetworkExclude
|
||||
// 例如: ["^eth0$", "^en0$", "^ens.*"]
|
||||
NetworkInclude []string `yaml:"network_include"`
|
||||
|
||||
// 网络采集排除的网卡列表(黑名单,支持正则表达式)
|
||||
// 仅当 NetworkInclude 为空时生效
|
||||
// 如果为空,使用默认排除规则(虚拟网卡、回环地址等)
|
||||
NetworkExclude []string `yaml:"network_exclude"`
|
||||
}
|
||||
|
||||
@@ -246,6 +252,56 @@ func (c *Config) Endpoint() string {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func DefaultNetworkExcludePatterns() []string {
|
||||
return []string{
|
||||
// 回环地址
|
||||
"^lo$",
|
||||
"^lo0$",
|
||||
// Linux 虚拟接口
|
||||
"^docker.*", // Docker 网卡
|
||||
"^veth.*", // 虚拟以太网接口
|
||||
"^br-.*", // 网桥接口
|
||||
"^virbr.*", // KVM/libvirt 网桥
|
||||
"^flannel.*", // Kubernetes Flannel
|
||||
"^cni.*", // Container Network Interface
|
||||
// macOS 虚拟接口
|
||||
"^anpi\\d+$", // Apple Network Process Interface
|
||||
"^ap\\d+$", // Apple Wireless Access Point
|
||||
"^awdl\\d+$", // Apple Wireless Direct Link (AirDrop)
|
||||
"^llw\\d+$", // Low Latency WLAN
|
||||
"^bridge\\d+$", // 桥接网络
|
||||
"^gif\\d+$", // Generic Tunnel Interface
|
||||
"^stf\\d+$", // 6to4 tunnel interface
|
||||
"^utun\\d+$", // User Tunnel (VPN)
|
||||
"^vmenet\\d+$", // 虚拟机网络 (VMware/Parallels)
|
||||
"^pktap\\d+$", // Packet capture interface
|
||||
"^ipsec\\d+$", // IPSec interface
|
||||
"^feth\\d+$", // Fake ethernet interface
|
||||
// Windows 虚拟接口
|
||||
"^Loopback.*",
|
||||
"^vEthernet.*",
|
||||
}
|
||||
}
|
||||
|
||||
// GetNetworkIncludePatterns 获取网络包含的正则表达式列表(白名单)
|
||||
func (c *Config) GetNetworkIncludePatterns() ([]*regexp.Regexp, error) {
|
||||
patterns := c.Collector.NetworkInclude
|
||||
if len(patterns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var regexps []*regexp.Regexp
|
||||
for _, pattern := range patterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编译网络包含规则 '%s' 失败: %w", pattern, err)
|
||||
}
|
||||
regexps = append(regexps, re)
|
||||
}
|
||||
|
||||
return regexps, nil
|
||||
}
|
||||
|
||||
// GetNetworkExcludePatterns 获取网络排除的正则表达式列表
|
||||
// 如果配置为空,返回默认排除规则(回环地址和常见虚拟网卡)
|
||||
func (c *Config) GetNetworkExcludePatterns() ([]*regexp.Regexp, error) {
|
||||
@@ -253,13 +309,7 @@ func (c *Config) GetNetworkExcludePatterns() ([]*regexp.Regexp, error) {
|
||||
|
||||
// 如果没有配置,使用默认排除规则
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{
|
||||
"^lo$", // 回环地址
|
||||
"^lo0$", // 回环地址
|
||||
"^docker.*", // Docker 网卡
|
||||
"^veth.*", // 虚拟以太网接口
|
||||
"^br-.*", // 网桥接口
|
||||
}
|
||||
patterns = DefaultNetworkExcludePatterns()
|
||||
}
|
||||
|
||||
var regexps []*regexp.Regexp
|
||||
@@ -275,14 +325,35 @@ func (c *Config) GetNetworkExcludePatterns() ([]*regexp.Regexp, error) {
|
||||
}
|
||||
|
||||
// ShouldExcludeNetworkInterface 检查网卡是否应该被排除
|
||||
// 逻辑:
|
||||
// 1. 如果配置了 NetworkInclude(白名单),则只保留匹配白名单的网卡
|
||||
// 2. 如果没有配置 NetworkInclude,则使用 NetworkExclude(黑名单)规则
|
||||
func (c *Config) ShouldExcludeNetworkInterface(interfaceName string) bool {
|
||||
patterns, err := c.GetNetworkExcludePatterns()
|
||||
// 优先检查白名单
|
||||
includePatterns, err := c.GetNetworkIncludePatterns()
|
||||
if err != nil {
|
||||
// 白名单编译失败,记录错误但继续使用黑名单逻辑
|
||||
// 这里可以考虑记录日志
|
||||
} else if len(includePatterns) > 0 {
|
||||
// 配置了白名单,检查是否匹配
|
||||
for _, pattern := range includePatterns {
|
||||
if pattern.MatchString(interfaceName) {
|
||||
// 匹配白名单,不排除
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 不在白名单中,排除
|
||||
return true
|
||||
}
|
||||
|
||||
// 没有配置白名单,使用黑名单逻辑
|
||||
excludePatterns, err := c.GetNetworkExcludePatterns()
|
||||
if err != nil {
|
||||
// 如果正则编译失败,使用默认规则
|
||||
return interfaceName == "lo" || interfaceName == "lo0"
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
for _, pattern := range excludePatterns {
|
||||
if pattern.MatchString(interfaceName) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -72,6 +72,35 @@ export const getAgentLatestMetrics = (agentId: string) => {
|
||||
return get<LatestMetrics>(`/agents/${agentId}/metrics/latest`);
|
||||
};
|
||||
|
||||
export interface GetNetworkMetricsByInterfaceRequest {
|
||||
agentId: string;
|
||||
range?: '1m' | '5m' | '15m' | '30m' | '1h';
|
||||
}
|
||||
|
||||
export interface NetworkMetricByInterface {
|
||||
timestamp: number;
|
||||
interface: string;
|
||||
maxSentRate: number;
|
||||
maxRecvRate: number;
|
||||
}
|
||||
|
||||
export interface GetNetworkMetricsByInterfaceResponse {
|
||||
agentId: string;
|
||||
type: string;
|
||||
range: string;
|
||||
start: number;
|
||||
end: number;
|
||||
interval: number;
|
||||
metrics: NetworkMetricByInterface[];
|
||||
}
|
||||
|
||||
export const getNetworkMetricsByInterface = (params: GetNetworkMetricsByInterfaceRequest) => {
|
||||
const {agentId, range = '1h'} = params;
|
||||
const query = new URLSearchParams();
|
||||
query.append('range', range);
|
||||
return get<GetNetworkMetricsByInterfaceResponse>(`/agents/${agentId}/metrics/network-by-interface?${query.toString()}`);
|
||||
};
|
||||
|
||||
// VPS 安全审计相关接口
|
||||
|
||||
export interface SystemInfo {
|
||||
|
||||
@@ -54,3 +54,13 @@ export const logout = () => {
|
||||
return post('/admin/logout');
|
||||
};
|
||||
|
||||
// 获取当前用户信息
|
||||
export interface CurrentUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const getCurrentUser = () => {
|
||||
return get<CurrentUser>('/admin/account/info');
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Activity, Github, Heart, Server} from 'lucide-react';
|
||||
import {Heart} from 'lucide-react';
|
||||
|
||||
const PublicFooter = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -8,9 +8,20 @@ const PublicFooter = () => {
|
||||
<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">
|
||||
<div
|
||||
className="flex flex-col items-center justify-between gap-3 text-xs text-slate-500 sm:flex-row">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>© {currentYear} Pika Monitor</span>
|
||||
<span>© {currentYear}</span>
|
||||
{/* GitHub 链接 */}
|
||||
<a
|
||||
href="https://github.com/dushixiang/pika"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="查看 GitHub 仓库"
|
||||
>
|
||||
<div className={' text-slate-500 underline'}>Pika Monitor</div>
|
||||
{/*<img src={GithubSvg} className="h-4 w-4" alt="GitHub"/>*/}
|
||||
</a>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span>保持洞察,稳定运行</span>
|
||||
</div>
|
||||
|
||||
+123
-110
@@ -1,132 +1,145 @@
|
||||
import {Activity, LayoutGrid, List, LogIn, Server} from 'lucide-react';
|
||||
import GithubSvg from "../assets/github.svg";
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Activity, LayoutGrid, List, LogIn, Server, Settings} from 'lucide-react';
|
||||
import {getCurrentUser} from '../api/auth';
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
interface PublicHeaderProps {
|
||||
title: string;
|
||||
lastUpdated?: string;
|
||||
viewMode?: 'grid' | 'list';
|
||||
onViewModeChange?: (mode: 'grid' | 'list') => void;
|
||||
showViewToggle?: boolean;
|
||||
}
|
||||
|
||||
const PublicHeader = ({
|
||||
title,
|
||||
lastUpdated,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
showViewToggle = false
|
||||
}: PublicHeaderProps) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
// 检查本地是否有 token
|
||||
const token = localStorage.getItem('token');
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
|
||||
if (!token || !userInfo) {
|
||||
setIsLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用后端接口验证 token 是否有效
|
||||
getCurrentUser()
|
||||
.then(() => {
|
||||
setIsLoggedIn(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// token 无效,清除本地存储
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
setIsLoggedIn(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 判断导航是否激活
|
||||
const isDeviceActive = currentPath === '/';
|
||||
const isMonitorActive = currentPath === '/monitors';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200/80 bg-white/80 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 左侧:品牌和标题 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center">
|
||||
<img src={'/logo.png'} className="h-10 w-10" alt={'logo'}/>
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white">
|
||||
<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">
|
||||
{/* 左侧:品牌和导航 */}
|
||||
<div className="flex items-center gap-3 sm:gap-6">
|
||||
{/* Logo 和品牌 */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<img src={'/logo.png'} className="h-8 w-8 sm:h-9 sm:w-9" alt={'logo'}/>
|
||||
<div className="hidden md:block">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.3em] text-blue-600">
|
||||
Pika Monitor
|
||||
</p>
|
||||
<h1 className="text-sm font-bold text-slate-900">皮卡监控</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.4em] text-blue-600">
|
||||
Pika Monitor
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-xl font-bold text-slate-900 lg:text-2xl">{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作区域 */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{/* 最后更新时间 */}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-lg bg-slate-50 px-3 py-2 text-slate-600">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-emerald-500"/>
|
||||
<span className="text-xs">
|
||||
最后更新:<span className="font-semibold text-slate-900">{lastUpdated}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden h-4 w-px bg-slate-200 lg:inline-block"/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 视图切换 */}
|
||||
{showViewToggle && viewMode && onViewModeChange && (
|
||||
<>
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-slate-500 hover:bg-white hover:text-blue-600'
|
||||
}`}
|
||||
title="网格视图"
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-slate-500 hover:bg-white hover:text-blue-600'
|
||||
}`}
|
||||
title="列表视图"
|
||||
>
|
||||
<List className="h-3.5 w-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
<span className="hidden h-4 w-px bg-slate-200 lg:inline-block"/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 导航链接 */}
|
||||
<nav className="flex items-center">
|
||||
{/* 服务器链接 */}
|
||||
<a
|
||||
href="/"
|
||||
className="group inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-600 transition-all"
|
||||
>
|
||||
<Server className="h-3.5 w-3.5 transition-transform group-hover:scale-110"/>
|
||||
<span className="hidden sm:inline">设备监控</span>
|
||||
</a>
|
||||
|
||||
{/* 监控链接 */}
|
||||
<a
|
||||
href="/monitors"
|
||||
className="group inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-slate-600"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 transition-transform group-hover:scale-110"/>
|
||||
<span className="hidden sm:inline">服务监控</span>
|
||||
</a>
|
||||
<nav className="flex items-center gap-1">
|
||||
<Link to="/">
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<Server className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
<span className="sm:inline">设备监控</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/monitors">
|
||||
<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'
|
||||
}`}>
|
||||
<Activity className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
<span className="sm:inline">服务监控</span>
|
||||
</div>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<span className="hidden h-4 w-px bg-slate-200 lg:inline-block"/>
|
||||
{/* 右侧:功能区 */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* 视图切换 */}
|
||||
{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">
|
||||
<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'
|
||||
}`}
|
||||
title="网格视图"
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
}`}
|
||||
title="列表视图"
|
||||
>
|
||||
<List className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub 链接 */}
|
||||
<a
|
||||
href="https://github.com/dushixiang/pika"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white p-2 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:shadow"
|
||||
title="查看 GitHub 仓库"
|
||||
>
|
||||
<img src={GithubSvg} className="h-4 w-4" alt="GitHub"/>
|
||||
</a>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<a
|
||||
href="/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r px-3 py-2 text-xs font-medium text-white shadow-sm shadow-blue-500/30 transition-all"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5"/>
|
||||
<span>登录</span>
|
||||
</a>
|
||||
{/* 登录/管理后台按钮 */}
|
||||
{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"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
<span className="sm:inline">管理后台</span>
|
||||
</a>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
|
||||
<span className="sm:inline">登录</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@ const AdminLayout = () => {
|
||||
className="!bg-white/20"
|
||||
/>
|
||||
<span className="text-xs font-medium">
|
||||
{userInfo?.nickname || userInfo?.username || '访客'}
|
||||
{userInfo?.username || '访客'}
|
||||
</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {Activity, ArrowLeft, Cpu, HardDrive, Loader2, MemoryStick, Network, Server, Box, Thermometer, Zap} from 'lucide-react';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {getAgent, getAgentLatestMetrics, getAgentMetrics, type GetAgentMetricsRequest} from '../../api/agent';
|
||||
import {getAgent, getAgentLatestMetrics, getAgentMetrics, type GetAgentMetricsRequest, getNetworkMetricsByInterface} from '../../api/agent';
|
||||
import type {
|
||||
Agent,
|
||||
AggregatedCPUMetric,
|
||||
@@ -79,6 +79,15 @@ const timeRangeOptions = [
|
||||
|
||||
type TimeRange = typeof timeRangeOptions[number]['value'];
|
||||
|
||||
// 网卡颜色配置(上行和下行使用不同的色调)
|
||||
const INTERFACE_COLORS = [
|
||||
{upload: '#6FD598', download: '#2C70F6'}, // 绿/蓝
|
||||
{upload: '#f59e0b', download: '#8b5cf6'}, // 橙/紫
|
||||
{upload: '#ec4899', download: '#06b6d4'}, // 粉/青
|
||||
{upload: '#10b981', download: '#f97316'}, // 翠绿/深橙
|
||||
{upload: '#14b8a6', download: '#2563eb'}, // 青绿/深蓝
|
||||
];
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
@@ -329,6 +338,39 @@ const useAggregatedMetrics = (agentId: string | undefined, range: TimeRange) =>
|
||||
return metrics;
|
||||
};
|
||||
|
||||
// 用于获取按网卡分组的网络指标
|
||||
const useNetworkMetricsByInterface = (agentId: string | undefined, range: TimeRange) => {
|
||||
const [networkMetrics, setNetworkMetrics] = useState<AggregatedNetworkMetric[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
setNetworkMetrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchNetworkMetrics = async () => {
|
||||
try {
|
||||
const response = await getNetworkMetricsByInterface({agentId, range});
|
||||
if (cancelled) return;
|
||||
setNetworkMetrics(response.data.metrics || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load network metrics by interface:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNetworkMetrics();
|
||||
const timer = setInterval(fetchNetworkMetrics, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [agentId, range]);
|
||||
|
||||
return networkMetrics;
|
||||
};
|
||||
|
||||
type SnapshotCardData = {
|
||||
key: string;
|
||||
icon: typeof Cpu;
|
||||
@@ -434,6 +476,7 @@ const ServerDetail = () => {
|
||||
const [selectedInterface, setSelectedInterface] = useState<string>('all');
|
||||
const {agent, latestMetrics, loading} = useAgentOverview(id);
|
||||
const metricsData = useAggregatedMetrics(id, timeRange);
|
||||
const networkMetricsByInterface = useNetworkMetricsByInterface(id, timeRange);
|
||||
|
||||
const cpuChartData = useMemo(
|
||||
() =>
|
||||
@@ -459,14 +502,14 @@ const ServerDetail = () => {
|
||||
[metricsData.memory]
|
||||
);
|
||||
|
||||
// 获取所有可用的网卡列表
|
||||
// 获取所有可用的网卡列表(从按网卡分组的数据中获取)
|
||||
const availableInterfaces = useMemo(() => {
|
||||
const interfaces = new Set<string>();
|
||||
metricsData.network.forEach((item) => {
|
||||
networkMetricsByInterface.forEach((item) => {
|
||||
interfaces.add(item.interface);
|
||||
});
|
||||
return Array.from(interfaces).sort();
|
||||
}, [metricsData.network]);
|
||||
}, [networkMetricsByInterface]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInterface === 'all') {
|
||||
@@ -478,37 +521,33 @@ const ServerDetail = () => {
|
||||
}, [availableInterfaces, selectedInterface]);
|
||||
|
||||
const networkChartData = useMemo(() => {
|
||||
const aggregated: Record<
|
||||
string,
|
||||
{ time: string; upload: number; download: number }
|
||||
> = {};
|
||||
if (networkMetricsByInterface.length === 0) return [];
|
||||
|
||||
// 根据选择的网卡过滤数据
|
||||
const filteredData = selectedInterface === 'all'
|
||||
? metricsData.network
|
||||
: metricsData.network.filter(item => item.interface === selectedInterface);
|
||||
|
||||
filteredData.forEach((item) => {
|
||||
// 按时间戳分组数据
|
||||
const grouped = networkMetricsByInterface.reduce((acc, item) => {
|
||||
const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (!aggregated[time]) {
|
||||
aggregated[time] = {time, upload: 0, download: 0};
|
||||
if (!acc[time]) {
|
||||
acc[time] = {time};
|
||||
}
|
||||
|
||||
// 聚合数据:使用平均速率(字节/秒)转换为 MB/s
|
||||
aggregated[time].upload += item.maxSentRate / 1024 / 1024;
|
||||
aggregated[time].download += item.maxRecvRate / 1024 / 1024;
|
||||
});
|
||||
// 根据选择的网卡过滤
|
||||
if (selectedInterface === 'all' || item.interface === selectedInterface) {
|
||||
const uploadKey = `${item.interface}_upload`;
|
||||
const downloadKey = `${item.interface}_download`;
|
||||
// 转换为 MB/s
|
||||
acc[time][uploadKey] = Number((item.maxSentRate / 1024 / 1024).toFixed(2));
|
||||
acc[time][downloadKey] = Number((item.maxRecvRate / 1024 / 1024).toFixed(2));
|
||||
}
|
||||
|
||||
return Object.values(aggregated).map((item) => ({
|
||||
...item,
|
||||
upload: Number(item.upload.toFixed(2)),
|
||||
download: Number(item.download.toFixed(2)),
|
||||
}));
|
||||
}, [metricsData.network, selectedInterface]);
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return Object.values(grouped);
|
||||
}, [networkMetricsByInterface, selectedInterface]);
|
||||
|
||||
// Disk I/O 图表数据(汇总所有磁盘)
|
||||
const diskIOChartData = useMemo(() => {
|
||||
@@ -962,26 +1001,38 @@ const ServerDetail = () => {
|
||||
style={{fontSize: '12px'}}
|
||||
tickFormatter={(value) => `${value} MB`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip unit=" MB"/>}/>
|
||||
<Tooltip content={<CustomTooltip unit=" MB/s"/>}/>
|
||||
<Legend/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="upload"
|
||||
name="上行"
|
||||
stroke="#6FD598"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="download"
|
||||
name="下行"
|
||||
stroke="#2C70F6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
/>
|
||||
{/* 动态渲染每个网卡的上行和下行曲线 */}
|
||||
{availableInterfaces
|
||||
.filter(iface => selectedInterface === 'all' || iface === selectedInterface)
|
||||
.map((iface, index) => {
|
||||
const colorConfig = INTERFACE_COLORS[index % INTERFACE_COLORS.length];
|
||||
const uploadKey = `${iface}_upload`;
|
||||
const downloadKey = `${iface}_download`;
|
||||
return (
|
||||
<React.Fragment key={iface}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={uploadKey}
|
||||
name={`${iface} 上行`}
|
||||
stroke={colorConfig.upload}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={downloadKey}
|
||||
name={`${iface} 下行`}
|
||||
stroke={colorConfig.download}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type ReactNode, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Cpu, EthernetPortIcon, HardDrive, Loader2, MemoryStick, Network} from 'lucide-react';
|
||||
import {listAgents} from '../../api/agent';
|
||||
@@ -130,16 +130,6 @@ const ServerList = () => {
|
||||
|
||||
const filteredAgents = agents;
|
||||
|
||||
const lastUpdatedDisplay =
|
||||
dataUpdatedAt && dataUpdatedAt > 0
|
||||
? new Date(dataUpdatedAt).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
: '尚未刷新';
|
||||
|
||||
|
||||
const handleNavigate = (agentId: string) => {
|
||||
navigate(`/servers/${agentId}`);
|
||||
};
|
||||
@@ -154,17 +144,10 @@ const ServerList = () => {
|
||||
const {totalUpload, totalDownload} = calculateNetworkTraffic(agent.metrics);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
key={agent.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleNavigate(agent.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleNavigate(agent.id);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
@@ -300,7 +283,7 @@ const ServerList = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -308,150 +291,6 @@ const ServerList = () => {
|
||||
|
||||
const renderListView = () => (
|
||||
<>
|
||||
{/* 移动端:使用卡片式布局 */}
|
||||
<div className="flex flex-col gap-4 lg:hidden">
|
||||
{filteredAgents.map((agent) => {
|
||||
const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0;
|
||||
const cpuModel = agent.metrics?.cpu?.modelName || '未知';
|
||||
const cpuPhysicalCores = agent.metrics?.cpu?.physicalCores ?? 0;
|
||||
const cpuLogicalCores = agent.metrics?.cpu?.logicalCores ?? 0;
|
||||
|
||||
const memoryUsage = agent.metrics?.memory?.usagePercent ?? 0;
|
||||
const memoryTotal = agent.metrics?.memory?.total ?? 0;
|
||||
const memoryUsed = agent.metrics?.memory?.used ?? 0;
|
||||
const memoryFree = agent.metrics?.memory?.free ?? 0;
|
||||
|
||||
const diskUsage = calculateDiskUsage(agent.metrics);
|
||||
const diskTotal = agent.metrics?.disk?.total ?? 0;
|
||||
const diskUsed = agent.metrics?.disk?.used ?? 0;
|
||||
const diskFree = agent.metrics?.disk?.free ?? 0;
|
||||
|
||||
const {upload, download} = calculateNetworkSpeed(agent.metrics);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleNavigate(agent.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleNavigate(agent.id);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded-xl border border-slate-200 bg-white p-4 transition hover:border-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
|
||||
>
|
||||
{/* 服务器信息 */}
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold text-slate-900">
|
||||
{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"/>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
{agent.os} · {agent.arch}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
{agent.platform && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-slate-100 px-2 py-0.5">
|
||||
<span className="font-medium">平台:</span> {agent.platform}
|
||||
</span>
|
||||
)}
|
||||
{agent.location && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-slate-100 px-2 py-0.5">
|
||||
<span className="font-medium">位置:</span> {agent.location}
|
||||
</span>
|
||||
)}
|
||||
{agent.expireTime > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-50 px-2 py-0.5 text-amber-700">
|
||||
<span className="font-medium">到期:</span> {new Date(agent.expireTime).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 监控指标 */}
|
||||
<div className="space-y-3">
|
||||
{/* CPU */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<Cpu className="h-3.5 w-3.5"/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700">CPU</span>
|
||||
<span className="ml-auto text-xs font-bold text-slate-900">
|
||||
{formatPercentValue(cpuUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={cpuUsage} colorClass={getProgressColor(cpuUsage)}/>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
<div className="truncate" title={cpuModel}>{cpuModel}</div>
|
||||
<div>{cpuPhysicalCores}核{cpuLogicalCores}线程</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内存 */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<MemoryStick className="h-3.5 w-3.5"/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700">内存</span>
|
||||
<span className="ml-auto text-xs font-bold text-slate-900">
|
||||
{formatPercentValue(memoryUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={memoryUsage} colorClass={getProgressColor(memoryUsage)}/>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
<div>总计:{formatBytes(memoryTotal)}</div>
|
||||
<div>已用:{formatBytes(memoryUsed)} / 剩余:{formatBytes(memoryFree)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 磁盘 */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<HardDrive className="h-3.5 w-3.5"/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700">磁盘</span>
|
||||
<span className="ml-auto text-xs font-bold text-slate-900">
|
||||
{formatPercentValue(diskUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={diskUsage} colorClass={getProgressColor(diskUsage)}/>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
<div>总计:{formatBytes(diskTotal)}</div>
|
||||
<div>已用:{formatBytes(diskUsed)} / 剩余:{formatBytes(diskFree)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网络 */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<Network className="h-3.5 w-3.5"/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700">网络速率</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-600">
|
||||
<span>↑ {formatSpeed(upload)}</span>
|
||||
<span>↓ {formatSpeed(download)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:使用表格布局 */}
|
||||
<div className="hidden overflow-hidden rounded-xl border border-slate-200 bg-white lg:block">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
@@ -497,6 +336,7 @@ const ServerList = () => {
|
||||
}}
|
||||
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"
|
||||
>
|
||||
|
||||
<td className="px-5 py-4 align-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -537,14 +377,16 @@ const ServerList = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-24">
|
||||
<ProgressBar percent={cpuUsage} colorClass={getProgressColor(cpuUsage)}/>
|
||||
<ProgressBar percent={cpuUsage}
|
||||
colorClass={getProgressColor(cpuUsage)}/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-900">
|
||||
{formatPercentValue(cpuUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<div className="truncate" style={{maxWidth: '200px'}} title={cpuModel}>{cpuModel}</div>
|
||||
<div className="truncate" style={{maxWidth: '200px'}}
|
||||
title={cpuModel}>{cpuModel}</div>
|
||||
<div>{cpuPhysicalCores}核{cpuLogicalCores}线程</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -570,7 +412,8 @@ const ServerList = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-24">
|
||||
<ProgressBar percent={diskUsage} colorClass={getProgressColor(diskUsage)}/>
|
||||
<ProgressBar percent={diskUsage}
|
||||
colorClass={getProgressColor(diskUsage)}/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-900">
|
||||
{formatPercentValue(diskUsage)}%
|
||||
@@ -602,8 +445,6 @@ const ServerList = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
|
||||
<PublicHeader
|
||||
title="设备监控"
|
||||
lastUpdated={lastUpdatedDisplay}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
@@ -624,7 +465,7 @@ const ServerList = () => {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
<PublicFooter/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 用户相关(简化版,仅用于登录)
|
||||
export interface User {
|
||||
username: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
|
||||
Reference in new issue
Block a user