From 1f365604a5f6fc2cc8be940e1cdcde462d544169 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 21 Nov 2025 22:34:06 +0800 Subject: [PATCH] =?UTF-8?q?-=20Agent=20=E5=A2=9E=E5=8A=A0=20info=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=8C=E5=B1=95=E7=A4=BA=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=9A=84=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=BF=A1=E6=81=AF=20-=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/agent/main.go | 71 +++++++- internal/app.go | 3 +- internal/handler/account_handler.go | 16 ++ internal/handler/agent_handler.go | 48 ++++++ internal/repo/metric_repo.go | 32 ++++ internal/service/account_service.go | 4 - internal/service/agent_service.go | 5 + pkg/agent/audit/check_process.go | 177 ++++++++++++++++--- pkg/agent/config/config.go | 93 ++++++++-- web/src/api/agent.ts | 29 ++++ web/src/api/auth.ts | 10 ++ web/src/components/PublicFooter.tsx | 17 +- web/src/components/PublicHeader.tsx | 233 ++++++++++++++------------ web/src/pages/AdminLayout/index.tsx | 2 +- web/src/pages/Public/ServerDetail.tsx | 143 +++++++++++----- web/src/pages/Public/ServerList.tsx | 183 ++------------------ web/src/types/index.ts | 1 - 17 files changed, 690 insertions(+), 377 deletions(-) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 910a41d..2601930 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -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() +} diff --git a/internal/app.go b/internal/app.go index 0f32434..989715b 100644 --- a/internal/app.go +++ b/internal/app.go @@ -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密钥管理 diff --git a/internal/handler/account_handler.go b/internal/handler/account_handler.go index 3507c6e..b51c615 100644 --- a/internal/handler/account_handler.go +++ b/internal/handler/account_handler.go @@ -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), + }) +} diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 88c3cc8..cc6742a 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -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") diff --git a/internal/repo/metric_repo.go b/internal/repo/metric_repo.go index faaf0e8..b5c3987 100644 --- a/internal/repo/metric_repo.go +++ b/internal/repo/metric_repo.go @@ -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"` diff --git a/internal/service/account_service.go b/internal/service/account_service.go index ca1e4c4..5c32e1f 100644 --- a/internal/service/account_service.go +++ b/internal/service/account_service.go @@ -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 } diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index deb7461..43367cd 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -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{} diff --git a/pkg/agent/audit/check_process.go b/pkg/agent/audit/check_process.go index 973fbb3..f74701f 100644 --- a/pkg/agent/audit/check_process.go +++ b/pkg/agent/audit/check_process.go @@ -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", "" } diff --git a/pkg/agent/config/config.go b/pkg/agent/config/config.go index 526c6e2..b6e84c4 100644 --- a/pkg/agent/config/config.go +++ b/pkg/agent/config/config.go @@ -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 } diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index b7eb2d3..7b2b035 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -72,6 +72,35 @@ export const getAgentLatestMetrics = (agentId: string) => { return get(`/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(`/agents/${agentId}/metrics/network-by-interface?${query.toString()}`); +}; + // VPS 安全审计相关接口 export interface SystemInfo { diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index aae4229..8d13392 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -54,3 +54,13 @@ export const logout = () => { return post('/admin/logout'); }; +// 获取当前用户信息 +export interface CurrentUser { + userId: string; + username: string; +} + +export const getCurrentUser = () => { + return get('/admin/account/info'); +}; + diff --git a/web/src/components/PublicFooter.tsx b/web/src/components/PublicFooter.tsx index 653ffe9..b52e14d 100644 --- a/web/src/components/PublicFooter.tsx +++ b/web/src/components/PublicFooter.tsx @@ -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 = () => {
{/* 底部版权信息 */}
-
+
- © {currentYear} Pika Monitor + © {currentYear} + {/* GitHub 链接 */} + +
Pika Monitor
+ {/*GitHub*/} +
· 保持洞察,稳定运行
diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx index 5c8eb41..c164fa2 100644 --- a/web/src/components/PublicHeader.tsx +++ b/web/src/components/PublicHeader.tsx @@ -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 ( -
-
-
- {/* 左侧:品牌和标题 */} -
-
- {'logo'}/ +
+
+
+ {/* 左侧:品牌和导航 */} +
+ {/* Logo 和品牌 */} +
+ {'logo'}/ +
+

+ Pika Monitor +

+

皮卡监控

+
-
-

- Pika Monitor -

-

{title}

-
-
- - {/* 右侧:操作区域 */} -
- {/* 最后更新时间 */} - {lastUpdated && ( - <> -
-
- - 最后更新:{lastUpdated} - -
- - - )} - - {/* 视图切换 */} - {showViewToggle && viewMode && onViewModeChange && ( - <> -
- - -
- - - )} {/* 导航链接 */} -
- + {/* 右侧:功能区 */} +
+ {/* 视图切换 */} + {showViewToggle && viewMode && onViewModeChange && ( +
+ + +
+ )} - {/* GitHub 链接 */} - - GitHub - - - {/* 登录按钮 */} - - - 登录 - + {/* 登录/管理后台按钮 */} + {isLoggedIn ? ( + + + 管理后台 + + ) : ( + + + 登录 + + )}
diff --git a/web/src/pages/AdminLayout/index.tsx b/web/src/pages/AdminLayout/index.tsx index 719ff6e..5ce3c67 100644 --- a/web/src/pages/AdminLayout/index.tsx +++ b/web/src/pages/AdminLayout/index.tsx @@ -166,7 +166,7 @@ const AdminLayout = () => { className="!bg-white/20" /> - {userInfo?.nickname || userInfo?.username || '访客'} + {userInfo?.username || '访客'} diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 01d724e..513a397 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -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 = () => (
@@ -329,6 +338,39 @@ const useAggregatedMetrics = (agentId: string | undefined, range: TimeRange) => return metrics; }; +// 用于获取按网卡分组的网络指标 +const useNetworkMetricsByInterface = (agentId: string | undefined, range: TimeRange) => { + const [networkMetrics, setNetworkMetrics] = useState([]); + + 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('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(); - 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); + + 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`} /> - }/> + }/> - - + {/* 动态渲染每个网卡的上行和下行曲线 */} + {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 ( + + + + + ); + })} ) : ( diff --git a/web/src/pages/Public/ServerList.tsx b/web/src/pages/Public/ServerList.tsx index e0a506a..834b5c3 100644 --- a/web/src/pages/Public/ServerList.tsx +++ b/web/src/pages/Public/ServerList.tsx @@ -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 ( -
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" >
@@ -300,7 +283,7 @@ const ServerList = () => {
-
+ ); })}
@@ -308,150 +291,6 @@ const ServerList = () => { const renderListView = () => ( <> - {/* 移动端:使用卡片式布局 */} -
- {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 ( -
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" - > - {/* 服务器信息 */} -
-
-
- - {agent.name || agent.hostname} - - - - 在线 - -
- - {agent.os} · {agent.arch} - -
-
- {agent.platform && ( - - 平台: {agent.platform} - - )} - {agent.location && ( - - 位置: {agent.location} - - )} - {agent.expireTime > 0 && ( - - 到期: {new Date(agent.expireTime).toLocaleDateString('zh-CN')} - - )} -
-
- - {/* 监控指标 */} -
- {/* CPU */} -
-
-
- -
- CPU - - {formatPercentValue(cpuUsage)}% - -
- -
-
{cpuModel}
-
{cpuPhysicalCores}核{cpuLogicalCores}线程
-
-
- - {/* 内存 */} -
-
-
- -
- 内存 - - {formatPercentValue(memoryUsage)}% - -
- -
-
总计:{formatBytes(memoryTotal)}
-
已用:{formatBytes(memoryUsed)} / 剩余:{formatBytes(memoryFree)}
-
-
- - {/* 磁盘 */} -
-
-
- -
- 磁盘 - - {formatPercentValue(diskUsage)}% - -
- -
-
总计:{formatBytes(diskTotal)}
-
已用:{formatBytes(diskUsed)} / 剩余:{formatBytes(diskFree)}
-
-
- - {/* 网络 */} -
-
-
- -
- 网络速率 -
-
- ↑ {formatSpeed(upload)} - ↓ {formatSpeed(download)} -
-
-
-
- ); - })} -
- {/* 桌面端:使用表格布局 */}
@@ -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" > +
@@ -537,14 +377,16 @@ const ServerList = () => {
- +
{formatPercentValue(cpuUsage)}%
-
{cpuModel}
+
{cpuModel}
{cpuPhysicalCores}核{cpuLogicalCores}线程
@@ -570,7 +412,8 @@ const ServerList = () => {
- +
{formatPercentValue(diskUsage)}% @@ -602,8 +445,6 @@ const ServerList = () => { return (
{
- +
); }; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index d41d3cb..5a63d09 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -1,7 +1,6 @@ // 用户相关(简化版,仅用于登录) export interface User { username: string; - nickname: string; } export interface LoginRequest {