From e91d1a106b3d6005e3ad8e28d2e87890b1bf75ae Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:42:08 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20Agent=20=E5=AE=89=E8=A3=85=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E5=B7=B2=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E6=97=A7=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重新安装 Agent 时如果服务已存在会报错 "Init already exists: /etc/systemd/system/pika-agent.service" 修改 Install() 方法,判断错误类型: - 如果是"服务已存在"错误,先停止卸载旧服务再重装 - 其他错误(如权限不足)直接返回 Co-Authored-By: Claude Opus 4.6 --- pkg/agent/service/service.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/agent/service/service.go b/pkg/agent/service/service.go index 65dc828..c93a9fa 100644 --- a/pkg/agent/service/service.go +++ b/pkg/agent/service/service.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "runtime" + "strings" "syscall" "github.com/dushixiang/pika/pkg/agent" @@ -203,7 +204,23 @@ func NewServiceManager(cfg *config.Config) (*ServiceManager, error) { // Install 安装服务 func (m *ServiceManager) Install() error { - return m.service.Install() + // 先尝试直接安装 + err := m.service.Install() + if err == nil { + return nil + } + + // 只有当是"服务已存在"的错误时才清理重装 + if strings.Contains(err.Error(), "already exists") { + slog.Info("服务已存在,先清理旧服务再重新安装...") + _ = m.service.Stop() + _ = m.service.Uninstall() + // 再次尝试安装 + return m.service.Install() + } + + // 其他错误直接返回 + return err } // Uninstall 卸载服务 From b504a704a612a199e04f0d99af9d101246549fb6 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:21:59 +0800 Subject: [PATCH 2/8] Update CyberCard.tsx --- web/src/portal/components/CyberCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/portal/components/CyberCard.tsx b/web/src/portal/components/CyberCard.tsx index 5524ce4..5204620 100644 --- a/web/src/portal/components/CyberCard.tsx +++ b/web/src/portal/components/CyberCard.tsx @@ -17,16 +17,16 @@ const CyberCard = ({className, children, animation, hover}: Props) => { )}> {/* 装饰性边框 - 仅在暗色模式下显示 */}
+ className="hidden dark:block absolute top-0 left-0 w-2 h-2 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300">
+ className="hidden dark:block absolute top-0 right-0 w-2 h-2 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300">
+ className="hidden dark:block absolute bottom-0 left-0 w-2 h-2 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300">
+ className="hidden dark:block absolute bottom-0 right-0 w-2 h-2 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300"> {animation &&
+ className="hidden dark:block absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent opacity-0 group-hover:opacity-100 -translate-y-full group-hover:translate-y-full transition-[transform,opacity] duration-1000 ease-in-out pointer-events-none will-change-transform"/> }
From 155ce40ee2a89b9a2a74242d0bd7c3509ed2c96d Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:15:05 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20pika=20log=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=94=A8=E4=BA=8E=E5=BF=AB=E9=80=9F=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=20Agent=20=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持实时跟踪(-f)、指定行数(-n)、强制查看服务日志(-s)等选项, 兼容 Linux (journalctl)、macOS (log show)、Windows (PowerShell)。 Co-Authored-By: Claude Opus 4.6 --- cmd/agent/main.go | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index ff5f297..b2de7b0 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -140,6 +141,20 @@ var registerCmd = &cobra.Command{ Run: registerAgent, } +// logCmd 查看日志命令 +var logCmd = &cobra.Command{ + Use: "log", + Short: "查看日志", + Long: `查看 Agent 运行日志,支持实时跟踪`, + Run: viewLog, +} + +var ( + logFollow bool + logLines int + logService bool +) + // infoCmd 信息命令 var infoCmd = &cobra.Command{ Use: "info", @@ -174,10 +189,16 @@ func init() { registerCmd.Flags().StringVarP(&agentName, "name", "n", "", "探针名称(默认使用主机名)") registerCmd.Flags().BoolVarP(&autoConfirm, "yes", "y", false, "自动确认配置并继续安装") + // log 命令参数 + logCmd.Flags().BoolVarP(&logFollow, "follow", "f", false, "实时跟踪日志输出") + logCmd.Flags().IntVarP(&logLines, "lines", "n", 100, "显示最近多少行日志") + logCmd.Flags().BoolVarP(&logService, "service", "s", false, "强制查看服务日志(跳过日志文件)") + // 添加子命令 rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(registerCmd) // 注册命令放在前面,方便用户发现 rootCmd.AddCommand(infoCmd) + rootCmd.AddCommand(logCmd) rootCmd.AddCommand(runCmd) rootCmd.AddCommand(installCmd) rootCmd.AddCommand(uninstallCmd) @@ -591,3 +612,140 @@ func showInfo(cmd *cobra.Command, args []string) { fmt.Printf(" 主机名: %s\n", hostname) fmt.Println() } + +// viewLog 查看日志 +func viewLog(cmd *cobra.Command, args []string) { + // 加载配置 + cfg, err := config.Load(configPath) + if err != nil { + log.Fatalf("❌ 加载配置失败: %v", err) + } + + // 判断是否以服务模式运行 + serviceRunning := false + if runtime.GOOS == "linux" { + // 检查 systemd 服务状态 + if out, err := exec.Command("systemctl", "is-active", "pika-agent").CombinedOutput(); err == nil && strings.TrimSpace(string(out)) == "active" { + serviceRunning = true + } + } else if runtime.GOOS == "darwin" { + if out, err := exec.Command("launchctl", "list", "org.pika.pika-agent").CombinedOutput(); err == nil && len(strings.TrimSpace(string(out))) > 0 { + serviceRunning = true + } + } else if runtime.GOOS == "windows" { + if out, err := exec.Command("sc", "query", "pika-agent").CombinedOutput(); err == nil && strings.Contains(string(out), "RUNNING") { + serviceRunning = true + } + } + + // 决定日志来源 + // 1. 如果指定了 --service 或者服务正在运行,查看服务日志 + // 2. 否则,查看日志文件 + if logService || serviceRunning { + viewServiceLog() + } else { + viewLogFile(cfg) + } +} + +// viewServiceLog 查看系统服务日志 +func viewServiceLog() { + args := []string{} + + switch runtime.GOOS { + case "linux": + // 使用 journalctl + args = append(args, "journalctl") + args = append(args, "-u", "pika-agent") + args = append(args, "-n", fmt.Sprintf("%d", logLines)) + if logFollow { + args = append(args, "-f") + } + case "darwin": + // 使用 log show + args = append(args, "log", "show") + args = append(args, "--predicate", `subsystem == "org.pika.pika-agent"`) + args = append(args, "--last", fmt.Sprintf("%d", logLines)) + if logFollow { + args = append(args, "--stream") + } + case "windows": + // 使用 PowerShell Get-WinEvent + psCmd := fmt.Sprintf("Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName='pika-agent'} -MaxEvents %d | Format-List TimeCreated, Message", logLines) + args = append(args, "powershell", "-NoProfile", "-Command", psCmd) + default: + log.Printf("⚠️ 当前系统 (%s) 不支持查看服务日志", runtime.GOOS) + return + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if logFollow { + fmt.Printf("📋 正在跟踪系统服务日志 (最近 %d 行)...\n", logLines) + fmt.Println("按 Ctrl+C 退出") + fmt.Println() + } else { + fmt.Printf("📋 系统服务日志 (最近 %d 行):\n", logLines) + fmt.Println(strings.Repeat("─", 50)) + } + + if err := cmd.Run(); err != nil { + log.Printf("⚠️ 查看服务日志失败: %v", err) + } +} + +// viewLogFile 查看日志文件 +func viewLogFile(cfg *config.Config) { + logFile := cfg.Agent.LogFile + if logFile == "" { + // 默认日志路径 + logFile = getHomeLogPath() + } + + // 检查日志文件是否存在 + if _, err := os.Stat(logFile); os.IsNotExist(err) { + log.Printf("⚠️ 日志文件不存在: %s", logFile) + fmt.Println() + fmt.Println("💡 提示: 如果 Agent 以服务方式运行,请使用以下命令查看日志:") + if runtime.GOOS == "linux" { + fmt.Println(" journalctl -u pika-agent -n 100 -f") + } else if runtime.GOOS == "darwin" { + fmt.Println(" log show --predicate 'subsystem == \"org.pika.pika-agent\"' --last 100") + } else if runtime.GOOS == "windows" { + fmt.Println(" Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName='pika-agent'} -MaxEvents 100") + } + fmt.Println() + fmt.Println("或者使用 --service 参数直接查看服务日志:") + fmt.Println(" agent log --service") + return + } + + if logFollow { + fmt.Printf("📋 正在跟踪日志: %s (最近 %d 行)...\n", logFile, logLines) + fmt.Println("按 Ctrl+C 退出") + fmt.Println(strings.Repeat("─", 50)) + + // 使用 tail -f + cmd := exec.Command("tail", "-f", "-n", fmt.Sprintf("%d", logLines), logFile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Printf("⚠️ 跟踪日志失败: %v", err) + } + } else { + fmt.Printf("📋 日志文件: %s (最近 %d 行)\n", logFile, logLines) + fmt.Println(strings.Repeat("─", 50)) + + // 读取文件最后 N 行 + cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", logLines), logFile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Printf("⚠️ 读取日志失败: %v", err) + } + } +} From ab22f9bd57941ca077dd7e74bce2cd1d045e283c Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:30:54 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E9=87=87=E9=9B=86=E7=A9=BA=E6=95=B0=E6=8D=AE=E6=97=B6=E8=AF=AF?= =?UTF-8?q?=E6=8A=A50%=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当磁盘或网卡采集不到有效数据时,空列表会被写入缓存导致 磁盘占用率和网络流量显示为0%,与真实值混淆。 修复方案: - Agent端:空列表不发送,与GPU/温度采集器保持一致 - Server端:空数据时跳过缓存更新,保留上一次有效值 Co-Authored-By: Claude Opus 4.6 --- internal/service/metric_service.go | 25 +++++++++++++++++++++---- pkg/agent/collector/disk.go | 5 +++++ pkg/agent/collector/manager.go | 10 ++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 354a9f2..5eb7038 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -184,6 +184,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &diskDataList); err != nil { return err } + // 无有效磁盘数据时,不更新缓存(保留上一次有效值) + if len(diskDataList) == 0 { + metrics := s.convertToMetrics(agentID, metricType, diskDataList, timestamp) + return s.vmClient.Write(ctx, metrics) + } // 计算汇总数据用于缓存 var totalTotal, totalUsed, totalFree uint64 for _, diskData := range diskDataList { @@ -191,10 +196,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me totalUsed += diskData.Used totalFree += diskData.Free } - var usagePercent float64 - if totalTotal > 0 { - usagePercent = float64(totalUsed) / float64(totalTotal) * 100 - } + usagePercent := float64(totalUsed) / float64(totalTotal) * 100 latestMetrics.Disk = &metric.DiskSummary{ UsagePercent: usagePercent, TotalDisks: len(diskDataList), @@ -210,6 +212,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &networkDataList); err != nil { return err } + // 无有效网络数据时,不更新缓存(保留上一次有效值) + if len(networkDataList) == 0 { + metrics := s.convertToMetrics(agentID, metricType, networkDataList, timestamp) + return s.vmClient.Write(ctx, metrics) + } // 计算汇总数据用于缓存 var totalSentRate, totalRecvRate uint64 var totalSentTotal, totalRecvTotal uint64 @@ -266,6 +273,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &gpuDataList); err != nil { return err } + // 无 GPU 数据时,不更新缓存 + if len(gpuDataList) == 0 { + metrics := s.convertToMetrics(agentID, metricType, gpuDataList, timestamp) + return s.vmClient.Write(ctx, metrics) + } // 更新缓存 latestMetrics.GPU = gpuDataList metrics := s.convertToMetrics(agentID, metricType, gpuDataList, timestamp) @@ -276,6 +288,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &tempDataList); err != nil { return err } + // 无温度数据时,不更新缓存 + if len(tempDataList) == 0 { + metrics := s.convertToMetrics(agentID, metricType, tempDataList, timestamp) + return s.vmClient.Write(ctx, metrics) + } // 更新缓存 latestMetrics.Temp = tempDataList metrics := s.convertToMetrics(agentID, metricType, tempDataList, timestamp) diff --git a/pkg/agent/collector/disk.go b/pkg/agent/collector/disk.go index 7bcf67e..ec61350 100644 --- a/pkg/agent/collector/disk.go +++ b/pkg/agent/collector/disk.go @@ -57,5 +57,10 @@ func (d *DiskCollector) Collect() ([]protocol.DiskData, error) { diskDataList = append(diskDataList, diskData) } + // 所有白名单分区均采集失败时,返回空列表表示异常 + if len(diskDataList) == 0 { + return nil, nil + } + return diskDataList, nil } diff --git a/pkg/agent/collector/manager.go b/pkg/agent/collector/manager.go index 5b8256c..7f13b8c 100644 --- a/pkg/agent/collector/manager.go +++ b/pkg/agent/collector/manager.go @@ -67,8 +67,9 @@ func (m *Manager) CollectAndSendMemory(conn WebSocketWriter) error { // CollectAndSendDisk 采集并发送磁盘指标 func (m *Manager) CollectAndSendDisk(conn WebSocketWriter) error { diskDataList, err := m.diskCollector.Collect() - if err != nil { - return err + if err != nil || len(diskDataList) == 0 { + // 采集失败或无有效数据,不发送空列表 + return nil } return m.sendMetrics(conn, protocol.MetricTypeDisk, diskDataList) } @@ -85,8 +86,9 @@ func (m *Manager) CollectAndSendDiskIO(conn WebSocketWriter) error { // CollectAndSendNetwork 采集并发送网络指标 func (m *Manager) CollectAndSendNetwork(conn WebSocketWriter) error { networkDataList, err := m.networkCollector.Collect() - if err != nil { - return err + if err != nil || len(networkDataList) == 0 { + // 采集失败或无有效数据,不发送空列表 + return nil } return m.sendMetrics(conn, protocol.MetricTypeNetwork, networkDataList) } From d56251a4e77869b2cabeb80c1a36f82db04e5c77 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:38:11 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=97=B6=E5=BC=BA=E5=88=B6=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E7=9A=84=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端在检测到 401 或本地无 token 时不再自动跳转到 /login, 改为仅清理本地缓存并抛出错误,由调用方自行决定后续行为。 Co-Authored-By: Claude Opus 4.6 --- web/src/admin/components/PrivateRoute.tsx | 5 ++--- web/src/admin/pages/AdminLayout/index.tsx | 1 - web/src/api/request.ts | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web/src/admin/components/PrivateRoute.tsx b/web/src/admin/components/PrivateRoute.tsx index d27aabc..82d5fee 100644 --- a/web/src/admin/components/PrivateRoute.tsx +++ b/web/src/admin/components/PrivateRoute.tsx @@ -1,4 +1,3 @@ -import { Navigate } from 'react-router-dom'; import type { ReactNode } from 'react'; interface PrivateRouteProps { @@ -13,9 +12,9 @@ const PrivateRoute = ({ children }: PrivateRouteProps) => { const token = localStorage.getItem('token'); const userInfo = localStorage.getItem('userInfo'); - // 如果没有登录信息,重定向到登录页 + // 如果没有登录信息,返回空占位 if (!token || !userInfo) { - return ; + return null; } // 已登录,渲染子组件 diff --git a/web/src/admin/pages/AdminLayout/index.tsx b/web/src/admin/pages/AdminLayout/index.tsx index 210a369..4b885b1 100644 --- a/web/src/admin/pages/AdminLayout/index.tsx +++ b/web/src/admin/pages/AdminLayout/index.tsx @@ -90,7 +90,6 @@ const AdminLayout = () => { const userInfoStr = localStorage.getItem('userInfo'); if (!token || !userInfoStr) { - navigate('/login'); return; } diff --git a/web/src/api/request.ts b/web/src/api/request.ts index dc42f9d..7c95398 100644 --- a/web/src/api/request.ts +++ b/web/src/api/request.ts @@ -81,7 +81,6 @@ const sendRequest = async (url: string, config: RequestConfig = {}): Promise< if (response.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('userInfo'); - window.location.href = '/login'; throw new HttpError('未认证或认证已过期', { status: response.status, statusText: response.statusText, From 7069c8ba6b5fb46ab249ac8fa9fe8cead35366c3 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:42:51 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20pika-agent=20log=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=9C=A8=20journal=20=E4=B8=BA=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=9B=9E=E9=80=80=E5=88=B0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 systemd journal 被轮转清空时,pika-agent log 只显示 -- No entries --, 不会回退到 agent 自己的日志文件。修复后 Linux 平台会先检查 journal 内容, 为空时自动切换到读取 ~/.pika/logs/agent.log。 Co-Authored-By: Claude Opus 4.7 --- cmd/agent/main.go | 98 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 21 deletions(-) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index b2de7b0..09e5a2f 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -642,45 +642,101 @@ func viewLog(cmd *cobra.Command, args []string) { // 1. 如果指定了 --service 或者服务正在运行,查看服务日志 // 2. 否则,查看日志文件 if logService || serviceRunning { - viewServiceLog() + viewServiceLog(cfg) } else { viewLogFile(cfg) } } // viewServiceLog 查看系统服务日志 -func viewServiceLog() { - args := []string{} - +func viewServiceLog(cfg *config.Config) { switch runtime.GOOS { case "linux": - // 使用 journalctl - args = append(args, "journalctl") - args = append(args, "-u", "pika-agent") - args = append(args, "-n", fmt.Sprintf("%d", logLines)) - if logFollow { - args = append(args, "-f") - } + viewLinuxServiceLog(cfg) case "darwin": - // 使用 log show - args = append(args, "log", "show") - args = append(args, "--predicate", `subsystem == "org.pika.pika-agent"`) - args = append(args, "--last", fmt.Sprintf("%d", logLines)) - if logFollow { - args = append(args, "--stream") - } + viewDarwinServiceLog() case "windows": - // 使用 PowerShell Get-WinEvent - psCmd := fmt.Sprintf("Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName='pika-agent'} -MaxEvents %d | Format-List TimeCreated, Message", logLines) - args = append(args, "powershell", "-NoProfile", "-Command", psCmd) + viewWindowsServiceLog() default: log.Printf("⚠️ 当前系统 (%s) 不支持查看服务日志", runtime.GOOS) + } +} + +// viewLinuxServiceLog Linux 系统服务日志查看(优先 journalctl,空时回退到日志文件) +func viewLinuxServiceLog(cfg *config.Config) { + args := []string{"journalctl", "-u", "pika-agent", "-n", fmt.Sprintf("%d", logLines)} + if logFollow { + args = append(args, "-f") + } + + // 非跟踪模式:先检查 journal 是否为空 + if !logFollow { + out, err := exec.Command(args[0], args[1:]...).CombinedOutput() + output := strings.TrimSpace(string(out)) + if err != nil || output == "" || strings.Contains(output, "-- No entries --") { + // journal 为空,回退到日志文件 + viewLogFile(cfg) + return + } + fmt.Printf("📋 系统服务日志 (最近 %d 行):\n", logLines) + fmt.Println(strings.Repeat("─", 50)) + fmt.Println(output) return } + // 跟踪模式:先快速检查 journal 是否有内容,再决定是否回退 + checkOut, _ := exec.Command("journalctl", "-u", "pika-agent", "-n", "1").CombinedOutput() + checkOutput := strings.TrimSpace(string(checkOut)) + if checkOutput == "" || strings.Contains(checkOutput, "-- No entries --") { + // journal 为空,回退到日志文件 + viewLogFile(cfg) + return + } + + fmt.Printf("📋 正在跟踪系统服务日志 (最近 %d 行)...\n", logLines) + fmt.Println("按 Ctrl+C 退出") + fmt.Println() + cmd := exec.Command(args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Printf("⚠️ 查看服务日志失败: %v", err) + } +} + +// viewDarwinServiceLog macOS 系统服务日志查看 +func viewDarwinServiceLog() { + args := []string{"log", "show"} + args = append(args, "--predicate", `subsystem == "org.pika.pika-agent"`) + args = append(args, "--last", fmt.Sprintf("%d", logLines)) + if logFollow { + args = append(args, "--stream") + } + + if logFollow { + fmt.Printf("📋 正在跟踪系统服务日志 (最近 %d 行)...\n", logLines) + fmt.Println("按 Ctrl+C 退出") + fmt.Println() + } else { + fmt.Printf("📋 系统服务日志 (最近 %d 行):\n", logLines) + fmt.Println(strings.Repeat("─", 50)) + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Printf("⚠️ 查看服务日志失败: %v", err) + } +} + +// viewWindowsServiceLog Windows 系统服务日志查看 +func viewWindowsServiceLog() { + psCmd := fmt.Sprintf("Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName='pika-agent'} -MaxEvents %d | Format-List TimeCreated, Message", logLines) + cmd := exec.Command("powershell", "-NoProfile", "-Command", psCmd) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr if logFollow { fmt.Printf("📋 正在跟踪系统服务日志 (最近 %d 行)...\n", logLines) From e0c9fc5b255da8f0b66d88960581af586fbf088b Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:43:06 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Agent=20?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB=E4=B8=8D=E8=A7=A6=E5=8F=91=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent 在连接断开后完全卡死 17 小时,根因分析如下: 1. 并发写竞争:pingLoop 直接使用 rawConn.WriteControl 与 metricsLoop 的 safeConn.WriteJSON 竞争同一个 gorilla websocket 内部锁 2. 连接写失败被吞掉:outboundWriter.WriteJSON 写失败时返回 nil, metricsLoop 感知不到连接错误,不会触发重连 3. 采集器无超时保护:任何采集器阻塞都会冻结整个 metricsLoop 修复: - safeConn 新增 WriteControl 方法,pingLoop 改用 safeConn 写入 - outboundWriter.WriteJSON 写失败时返回真实错误 - collectAndSendAllMetrics 增加 30 秒全局超时保护 - 连接写失败视为连接异常,返回错误触发重连 - 新增采集耗时监控日志(耗时超过 500ms 时记录) Co-Authored-By: Claude Opus 4.7 --- pkg/agent/service/agent.go | 160 +++++++++++++++++++++------- pkg/agent/service/metrics_buffer.go | 2 +- 2 files changed, 122 insertions(+), 40 deletions(-) diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 98e0f3e..71028ab 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -28,9 +28,10 @@ import ( ) const ( - agentPingInterval = 10 * time.Second - agentPongWait = 30 * time.Second - agentWriteWait = 5 * time.Second + agentPingInterval = 10 * time.Second + agentPongWait = 30 * time.Second + agentWriteWait = 5 * time.Second + agentCollectTimeout = 30 * time.Second ) // safeConn 线程安全的 WebSocket 连接包装器 @@ -63,6 +64,13 @@ func (sc *safeConn) Close() error { return sc.conn.Close() } +// WriteControl 线程安全地写入控制消息 +func (sc *safeConn) WriteControl(messageType int, data []byte, deadline time.Time) error { + sc.mu.Lock() + defer sc.mu.Unlock() + return sc.conn.WriteControl(messageType, data, deadline) +} + // Agent 探针服务 type Agent struct { cfg *config.Config @@ -222,7 +230,7 @@ func (a *Agent) runOnce(ctx context.Context, onRegistered func()) error { // 主动发送 Ping,快速检测断线 wg.Go(func() { - if err := a.pingLoop(ctx, rawConn, done); err != nil { + if err := a.pingLoop(ctx, conn, done); err != nil { select { case errChan <- fmt.Errorf("ping失败: %w", err): default: @@ -314,7 +322,7 @@ func (a *Agent) readLoop(conn *websocket.Conn, done chan struct{}) error { } } -func (a *Agent) pingLoop(ctx context.Context, conn *websocket.Conn, done chan struct{}) error { +func (a *Agent) pingLoop(ctx context.Context, conn *safeConn, done chan struct{}) error { ticker := time.NewTicker(agentPingInterval) defer ticker.Stop() @@ -529,6 +537,42 @@ func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager) error { return fmt.Errorf("采集器未初始化") } + type result struct { + err error + } + done := make(chan result, 1) + + go func() { + done <- result{err: a.doCollectAndSend(manager)} + }() + + select { + case r := <-done: + return r.err + case <-time.After(agentCollectTimeout): + return fmt.Errorf("指标采集超时 (超过 %v)", agentCollectTimeout) + } +} + +// collectTimer 采集耗时计时器,记录每个采集器的耗时 +type collectTimer struct { + name string + start time.Time +} + +func newCollectTimer(name string) *collectTimer { + return &collectTimer{name: name, start: time.Now()} +} + +func (t *collectTimer) done() { + d := time.Since(t.start) + if d > 500*time.Millisecond { + slog.Info("采集耗时", "collector", t.name, "duration", d) + } +} + +// doCollectAndSend 执行实际的采集和发送 +func (a *Agent) doCollectAndSend(manager *collector.Manager) error { conn := a.getActiveConn() if conn != nil { if sent, err := a.outboundBuffer.Flush(conn); err != nil { @@ -543,56 +587,92 @@ func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager) error { var hasError bool // CPU 动态指标 - if err := manager.CollectAndSendCPU(bw); err != nil { - slog.Warn("采集CPU指标失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("cpu") + defer t.done() + if err := manager.CollectAndSendCPU(bw); err != nil { + slog.Warn("采集CPU指标失败", "error", err) + hasError = true + } + }() // 内存动态指标 - if err := manager.CollectAndSendMemory(bw); err != nil { - slog.Warn("采集内存指标失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("memory") + defer t.done() + if err := manager.CollectAndSendMemory(bw); err != nil { + slog.Warn("采集内存指标失败", "error", err) + hasError = true + } + }() // 磁盘指标 - if err := manager.CollectAndSendDisk(bw); err != nil { - slog.Warn("采集磁盘指标失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("disk") + defer t.done() + if err := manager.CollectAndSendDisk(bw); err != nil { + slog.Warn("采集磁盘指标失败", "error", err) + hasError = true + } + }() // 磁盘 IO 指标 - if err := manager.CollectAndSendDiskIO(bw); err != nil { - slog.Warn("采集磁盘IO指标失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("disk_io") + defer t.done() + if err := manager.CollectAndSendDiskIO(bw); err != nil { + slog.Warn("采集磁盘IO指标失败", "error", err) + hasError = true + } + }() // 网络指标 - if err := manager.CollectAndSendNetwork(bw); err != nil { - slog.Warn("采集网络指标失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("network") + defer t.done() + if err := manager.CollectAndSendNetwork(bw); err != nil { + slog.Warn("采集网络指标失败", "error", err) + hasError = true + } + }() // 网络连接统计 - if err := manager.CollectAndSendNetworkConnection(bw); err != nil { - slog.Warn("采集网络连接统计失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("network_connection") + defer t.done() + if err := manager.CollectAndSendNetworkConnection(bw); err != nil { + slog.Warn("采集网络连接统计失败", "error", err) + hasError = true + } + }() // 主机信息(包含 Load) - if err := manager.CollectAndSendHost(bw); err != nil { - slog.Warn("采集主机信息失败", "error", err) - hasError = true - } + func() { + t := newCollectTimer("host") + defer t.done() + if err := manager.CollectAndSendHost(bw); err != nil { + slog.Warn("采集主机信息失败", "error", err) + hasError = true + } + }() // GPU 信息(可选) - if err := manager.CollectAndSendGPU(bw); err != nil { - slog.Info("采集GPU信息失败", "error", err) - } + func() { + t := newCollectTimer("gpu") + defer t.done() + if err := manager.CollectAndSendGPU(bw); err != nil { + slog.Info("采集GPU信息失败", "error", err) + } + }() // 温度信息(可选) - if err := manager.CollectAndSendTemperature(bw); err != nil { - slog.Info("采集温度信息失败", "error", err) - } + func() { + t := newCollectTimer("temperature") + defer t.done() + if err := manager.CollectAndSendTemperature(bw); err != nil { + slog.Info("采集温度信息失败", "error", err) + } + }() // 发送打包后的指标 if len(bw.items) > 0 { @@ -613,6 +693,8 @@ func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager) error { slog.Info("当前连接不可用,消息已写入缓存") } else if writer.sendErr != nil { slog.Warn("发送消息失败,已写入缓存", "error", writer.sendErr) + // 连接写失败视为连接异常,返回错误触发重连 + return fmt.Errorf("连接发送失败: %w", writer.sendErr) } } diff --git a/pkg/agent/service/metrics_buffer.go b/pkg/agent/service/metrics_buffer.go index f97da7f..cc52008 100644 --- a/pkg/agent/service/metrics_buffer.go +++ b/pkg/agent/service/metrics_buffer.go @@ -236,7 +236,7 @@ func (w *outboundWriter) WriteJSON(v interface{}) error { } w.buffered = true w.sendErr = err - return nil + return err } return nil From c2e64804f3726b564ea536a2fd0366e522d4a7f0 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:43:16 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20GPU=20=E9=87=87=E9=9B=86=E5=99=A8=20?= =?UTF-8?q?nvidia-smi=20=E8=B0=83=E7=94=A8=E6=B7=BB=E5=8A=A0=2015=20?= =?UTF-8?q?=E7=A7=92=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nvidia-smi 在 GPU 驱动异常时可能无限挂起,导致整个 metricsLoop 卡死。 使用 exec.CommandContext 为 initStatic 和 collectDynamic 中的 nvidia-smi 调用添加 15 秒超时保护。 Co-Authored-By: Claude Opus 4.7 --- pkg/agent/collector/gpu.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/agent/collector/gpu.go b/pkg/agent/collector/gpu.go index 91918fe..4cbfb4f 100644 --- a/pkg/agent/collector/gpu.go +++ b/pkg/agent/collector/gpu.go @@ -1,15 +1,19 @@ package collector import ( + "context" "encoding/csv" "os/exec" "strconv" "strings" "sync" + "time" "github.com/dushixiang/pika/internal/protocol" ) +const nvidiaSmiTimeout = 15 * time.Second + // gpuStaticInfo GPU 静态信息 type gpuStaticInfo struct { Index int @@ -42,7 +46,9 @@ func (g *GPUCollector) initStatic() { } // 查询静态信息: index, name, uuid, memory.total - cmd := exec.Command("nvidia-smi", + ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,name,uuid,memory.total", "--format=csv,noheader,nounits") @@ -105,7 +111,9 @@ func (g *GPUCollector) Collect() ([]*protocol.GPUData, error) { func (g *GPUCollector) collectDynamic() ([]*protocol.GPUData, error) { // 使用 nvidia-smi 查询动态数据 // 输出格式: index, temperature.gpu, utilization.gpu, memory.used, memory.free, power.draw, fan.speed - cmd := exec.Command("nvidia-smi", + ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,temperature.gpu,utilization.gpu,memory.used,memory.free,power.draw,fan.speed", "--format=csv,noheader,nounits")