- Agent 增加 info 命令,展示连接的服务器信息

- 优化页面
This commit is contained in:
dushixiang committed 2025-11-21 22:34:06 +08:00
1 parent a9fc74e87d
commit 1f365604a5
17 files changed
+690 -377

No files matched your search

+64 -7
View File
@@ -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
View File
@@ -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密钥管理
+16
View File
@@ -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),
})
}
+48
View File
@@ -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")
+32
View File
@@ -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"`
-4
View File
@@ -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
}
+5
View File
@@ -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{}
+155 -22
View File
@@ -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
View File
@@ -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
}
+29
View File
@@ -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 {
+10
View File
@@ -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');
};
+14 -3
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>
+97 -46
View File
@@ -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>
) : (
+12 -171
View File
@@ -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
View File
@@ -1,7 +1,6 @@
// 用户相关(简化版,仅用于登录)
export interface User {
username: string;
nickname: string;
}
export interface LoginRequest {