diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d9ad9..553a058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [v1.2.4] - 2026-09-24 + +### 新增 + +- 公网 Agent 能力扩展(agent 0.10.0):新增三个只读端点——`GET /api/v1/metrics`(VPS 主机资源:CPU / 内存 / 磁盘 / 负载 / 运行时长,CPU 用两次采样差值,1 秒内的重复请求直接复用上次结果)、`GET /api/v1/logs/nginx`(access / error 日志尾部,路径只从 `nginx -T` 实际生效配置、`FONU_AGENT_NGINX_LOG_DIR` 或默认目录取;按 32KB 块倒读,单行 4KB 截断,上限 2000 行)、`GET /api/v1/frps/config`(frps.toml 回读,256KB 上限,文件不存在返回 `exists: false`) +- 公网 Agent 页:新增「VPS 资源」横排带(CPU / 内存 / 磁盘三项并排 + 进度条 + 右侧平均负载与运行时长),占满「环境与依赖」整行,不做单独占格 +- 日志中心:新增「公网 Nginx」页签(服务端 / 访问或错误日志 / 行数 200·500·2000 + 关键词搜索 + 分页),并显示 agent 解析出的实际日志路径与「仅显示尾部」标记 +- 公网服务端「frps.toml 同源配置」弹窗:新增「比对 VPS 实际文件」——去注释与空行后逐行比对,一致给出绿色说明,不一致指出第一个差异行(下发值 / VPS 值) + +### 改进 + +- `internal/sysinfo` 补 `LoadAvg()` 与 `UptimeSeconds()`:Linux 读 `/proc/loadavg`、`/proc/uptime`,Windows 用 `GetTickCount64`,其它平台返回空值 +- 老版本 agent 的降级:三个新端点遇到 HTTP 404/405 时统一转成「VPS 上的 agent 版本过低,不支持该接口(需 ≥ 0.10.0),请在服务端详情里升级 Agent」的可操作提示,而不是原始 HTTP 错误 + +### 变更 + +- `AgentVersion` 0.9.2 → **0.10.0**;内嵌 `fonu-agent-linux-amd64`(9.9 MB)与 `-arm64`(9.2 MB)已用新代码重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.4(版本单一来源仍是根目录 `VERSION`) + ## [v1.2.3] - 2026-09-24 ### 新增 diff --git a/VERSION b/VERSION index 0495c4a..e8ea05d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.3 +1.2.4 diff --git a/internal/agent/insights.go b/internal/agent/insights.go new file mode 100644 index 0000000..0db2b6d --- /dev/null +++ b/internal/agent/insights.go @@ -0,0 +1,233 @@ +package agent + +import ( + "bytes" + "fmt" + "io" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/fonu/fonu/internal/sysinfo" +) + +const ( + // metricsInterval 两次 CPU 采样的最小间隔:间隔内重复调用直接返回上次结果,避免数值抖动 + metricsInterval = time.Second + // maxLogLineBytes 单行日志截断长度,避免一行超长把响应撑大 + maxLogLineBytes = 4 << 10 + // maxLogLines 日志尾部行数上限 + maxLogLines = 2000 + // maxFRPSConfigBytes frps.toml 回读上限 + maxFRPSConfigBytes = 256 << 10 +) + +// metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)。 +// 复用 internal/sysinfo:agent 跑在 VPS 上,采到的天然是 VPS 自身数值。 +func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) { + s.metricsMu.Lock() + defer s.metricsMu.Unlock() + + now := time.Now() + if s.metricsCached != nil && now.Sub(s.metricsAt) < metricsInterval { + writeJSON(w, http.StatusOK, s.metricsCached) + return + } + + stats, sample := sysinfo.Read(s.metricsSample) + s.metricsSample = sample + payload := map[string]any{ + "cpu_percent": math.Round(stats.CPUPercent*10) / 10, + "mem_used_bytes": stats.MemUsedBytes, + "mem_total_bytes": stats.MemTotalBytes, + "disk_used_bytes": stats.DiskUsedBytes, + "disk_total_bytes": stats.DiskTotalBytes, + // 当前只统计根文件系统(与 internal/sysinfo 的 Linux 实现一致) + "disk_path": "/", + "checked_at": now.UTC().Format(time.RFC3339), + } + if load := sysinfo.LoadAvg(); len(load) == 3 { + payload["load_avg"] = load + } + if uptime := sysinfo.UptimeSeconds(); uptime > 0 { + payload["uptime_seconds"] = uptime + } + + s.metricsAt = now + s.metricsCached = payload + writeJSON(w, http.StatusOK, payload) +} + +// nginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部。 +// 路径不接受请求参数:只从 `nginx -T` 的实际生效配置、默认目录或环境变量目录里取,避免变成任意文件读取。 +func (s *Server) nginxLogs(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.URL.Query().Get("type")) + if kind != "access" && kind != "error" { + kind = "access" + } + lines := maxLogLines + if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + lines = parsed + } + } + if lines <= 0 || lines > maxLogLines { + lines = maxLogLines + } + + path := s.nginxLogPath(kind) + if path == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "type": kind, "path": "", "lines": []string{}, "truncated": false, + "checked_at": time.Now().UTC().Format(time.RFC3339), + }) + return + } + + content, truncated, err := tailFile(path, lines, maxLogLineBytes) + if err != nil { + if os.IsNotExist(err) { + // 文件不存在不是错误:返回空列表 + 路径,由前端提示「未找到日志文件」 + writeJSON(w, http.StatusOK, map[string]any{ + "type": kind, "path": path, "lines": []string{}, "truncated": false, + "checked_at": time.Now().UTC().Format(time.RFC3339), + }) + return + } + status := http.StatusInternalServerError + if os.IsPermission(err) { + status = http.StatusForbidden + } + writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取日志失败:%v", err), "path": path}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "type": kind, "path": path, "lines": content, "truncated": truncated, + "checked_at": time.Now().UTC().Format(time.RFC3339), + }) +} + +// nginxLogPath 解析日志文件路径:优先 `nginx -T` 实际生效配置里的指令,其次环境变量目录,最后默认目录 +func (s *Server) nginxLogPath(kind string) string { + directive := "access_log" + if kind == "error" { + directive = "error_log" + } + if nginx := s.nginxPath(); nginx != "" { + if out, err := exec.Command(nginx, "-T").CombinedOutput(); err == nil { + if path := logPathFromDump(string(out), directive); path != "" { + return path + } + } + } + dir := strings.TrimSpace(os.Getenv("FONU_AGENT_NGINX_LOG_DIR")) + if dir == "" { + dir = "/var/log/nginx" + } + return filepath.Join(dir, kind+".log") +} + +// logPathFromDump 从 `nginx -T` 输出里取第一条绝对路径的日志指令(跳过 off / stderr / 含变量的写法) +func logPathFromDump(dump, directive string) string { + pattern := regexp.MustCompile(`(?m)^\s*` + directive + `\s+([^;\s]+)`) + for _, match := range pattern.FindAllStringSubmatch(dump, -1) { + candidate := strings.TrimSpace(match[1]) + if candidate == "" || candidate == "off" || candidate == "stderr" || strings.Contains(candidate, "$") { + continue + } + // 只接受绝对路径:agent 只跑在 Linux 上,用前导斜杠判定(filepath.IsAbs 在 Windows 上对 /var/... 返回 false) + if strings.HasPrefix(candidate, "/") { + return candidate + } + } + return "" +} + +// tailFile 从文件末尾倒读最多 lines 行:按块回溯,避免把整个日志读进内存;单行超长时截断 +func tailFile(path string, lines int, maxLine int) ([]string, bool, error) { + file, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, false, err + } + + const chunkSize = 32 * 1024 + var ( + offset int64 = info.Size() + buffer []byte + ) + for offset > 0 && bytes.Count(buffer, []byte("\n")) <= lines { + readSize := int64(chunkSize) + if offset < readSize { + readSize = offset + } + offset -= readSize + block := make([]byte, readSize) + if _, err := file.ReadAt(block, offset); err != nil && err != io.EOF { + return nil, false, err + } + buffer = append(block, buffer...) + } + + rows := strings.Split(strings.TrimRight(string(buffer), "\n"), "\n") + truncated := false + if len(rows) > lines { + rows = rows[len(rows)-lines:] + truncated = true + } + out := make([]string, 0, len(rows)) + for _, row := range rows { + if len(row) > maxLine { + row = row[:maxLine] + "…" + } + out = append(out, row) + } + return out, truncated, nil +} + +// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况 +func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) { + path := strings.TrimSpace(s.cfg.FrpsConfigPath) + payload := map[string]any{ + "path": path, "exists": false, "content": "", "truncated": false, + "checked_at": time.Now().UTC().Format(time.RFC3339), + } + if path == "" { + writeJSON(w, http.StatusOK, payload) + return + } + info, err := os.Stat(path) + if err != nil { + writeJSON(w, http.StatusOK, payload) + return + } + data, err := os.ReadFile(path) + if err != nil { + status := http.StatusInternalServerError + if os.IsPermission(err) { + status = http.StatusForbidden + } + writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取 frps 配置失败:%v", err), "path": path}) + return + } + if len(data) > maxFRPSConfigBytes { + data = data[:maxFRPSConfigBytes] + payload["truncated"] = true + } + payload["exists"] = true + payload["content"] = string(data) + payload["mod_time"] = info.ModTime().UTC().Format(time.RFC3339) + writeJSON(w, http.StatusOK, payload) +} diff --git a/internal/agent/insights_test.go b/internal/agent/insights_test.go new file mode 100644 index 0000000..5104cbd --- /dev/null +++ b/internal/agent/insights_test.go @@ -0,0 +1,67 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTailFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + if err := os.WriteFile(path, []byte("line1\nline2\nline3\nline4\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + rows, truncated, err := tailFile(path, 2, maxLogLineBytes) + if err != nil { + t.Fatalf("tailFile: %v", err) + } + // 文件里还有更早的行没返回,因此应当标记为截断 + if !truncated { + t.Fatal("只取两行时应标记为截断(还有更早的日志未返回)") + } + if len(rows) != 2 || rows[0] != "line3" || rows[1] != "line4" { + t.Fatalf("unexpected rows: %#v", rows) + } + + // 请求行数超过文件内容:返回全部且不标记截断 + rows, truncated, err = tailFile(path, 100, maxLogLineBytes) + if err != nil || truncated || len(rows) != 4 { + t.Fatalf("unexpected full read: rows=%d truncated=%v err=%v", len(rows), truncated, err) + } + + // 单行超长时截断 + long := strings.Repeat("x", maxLogLineBytes+10) + if err := os.WriteFile(path, []byte(long+"\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + rows, _, err = tailFile(path, 5, maxLogLineBytes) + if err != nil { + t.Fatalf("tailFile: %v", err) + } + if len(rows) != 1 || !strings.HasSuffix(rows[0], "…") || len(rows[0]) > maxLogLineBytes+3 { + t.Fatalf("超长行未被截断:len=%d", len(rows[0])) + } + + // 文件不存在返回错误,由调用方转成「200 + 空列表」 + if _, _, err := tailFile(filepath.Join(t.TempDir(), "missing.log"), 10, maxLogLineBytes); err == nil { + t.Fatal("文件不存在应返回错误") + } +} + +func TestLogPathFromDump(t *testing.T) { + dump := "# configuration file /etc/nginx/nginx.conf:\naccess_log /var/log/nginx/access.log main;\nerror_log /var/log/nginx/error.log warn;\n" + if got := logPathFromDump(dump, "access_log"); got != "/var/log/nginx/access.log" { + t.Fatalf("unexpected access log: %q", got) + } + if got := logPathFromDump(dump, "error_log"); got != "/var/log/nginx/error.log" { + t.Fatalf("unexpected error log: %q", got) + } + + // off / stderr / 相对路径 / 含变量 都不采用 + skip := "access_log off;\naccess_log stderr;\naccess_log logs/access.log;\naccess_log /var/log/nginx/$host.log;\n" + if got := logPathFromDump(skip, "access_log"); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index 97f3979..201e905 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -23,6 +23,8 @@ import ( "strings" "sync" "time" + + "github.com/fonu/fonu/internal/sysinfo" ) // AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。 @@ -31,7 +33,8 @@ import ( // 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。 // 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。 // 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。 -const AgentVersion = "0.9.2" +// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。 +const AgentVersion = "0.10.0" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -49,6 +52,11 @@ type Config struct { type Server struct { cfg Config mu sync.Mutex + // metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果 + metricsMu sync.Mutex + metricsAt time.Time + metricsSample sysinfo.CPUSample + metricsCached map[string]any // nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程 nginxStatusMu sync.Mutex nginxStatusAt time.Time @@ -160,6 +168,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS)) mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction)) mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate)) + mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics)) + mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs)) + mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig)) return mux } diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index c73ace5..c9b36db 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "net/http" + "strconv" + "strings" "github.com/fonu/fonu/internal/frp" ) @@ -498,6 +500,52 @@ func (h *FRPMultiHandler) AgentRedeployRoutes(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusOK, result) } +// AgentMetrics 返回 VPS 主机资源(需 agent ≥ 0.10.0) +func (h *FRPMultiHandler) AgentMetrics(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + metrics, err := h.svc.AgentMetrics(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, metrics) +} + +// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部(需 agent ≥ 0.10.0) +func (h *FRPMultiHandler) AgentNginxLogs(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines"))) + logs, err := h.svc.AgentNginxLogs(r.Context(), id, strings.TrimSpace(r.URL.Query().Get("type")), lines) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, logs) +} + +// AgentFRPSConfig 回读 VPS 上的 frps.toml(需 agent ≥ 0.10.0) +func (h *FRPMultiHandler) AgentFRPSConfig(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效") + return + } + config, err := h.svc.AgentFRPSConfig(r.Context(), id) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, config) +} + func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) { id, err := parseID(r.PathValue("id")) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index c41c0c9..e101dfc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -118,6 +118,9 @@ func NewRouter(deps Deps) http.Handler { protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary) protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift) protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes) + protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics) + protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs) + protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig) protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx) protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf) protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates) diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 58a7857..7957138 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" + "strconv" "strings" "time" ) @@ -54,7 +57,7 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("agent 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))} } if result != nil && len(data) > 0 { if err := json.Unmarshal(data, result); err != nil { @@ -74,6 +77,61 @@ func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) { return result, err } +// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本 +const agentMinVersionInsights = "0.10.0" + +// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405) +var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口") + +// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误 +type statusError struct { + code int + body string +} + +func (e *statusError) Error() string { + return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body) +} + +// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误,其它错误原样返回 +func wrapEndpointMissing(err error) error { + var statusErr *statusError + if errors.As(err, &statusErr) && (statusErr.code == http.StatusNotFound || statusErr.code == http.StatusMethodNotAllowed) { + return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, agentMinVersionInsights) + } + return err +} + +// Metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长) +func (c *AgentClient) Metrics(ctx context.Context) (map[string]any, error) { + var result map[string]any + if err := c.request(ctx, http.MethodGet, "/api/v1/metrics", nil, &result); err != nil { + return nil, wrapEndpointMissing(err) + } + return result, nil +} + +// NginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部 +func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (map[string]any, error) { + query := url.Values{} + query.Set("type", kind) + query.Set("lines", strconv.Itoa(lines)) + var result map[string]any + if err := c.request(ctx, http.MethodGet, "/api/v1/logs/nginx?"+query.Encode(), nil, &result); err != nil { + return nil, wrapEndpointMissing(err) + } + return result, nil +} + +// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对) +func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) { + var result map[string]any + if err := c.request(ctx, http.MethodGet, "/api/v1/frps/config", nil, &result); err != nil { + return nil, wrapEndpointMissing(err) + } + return result, nil +} + func (c *AgentClient) SyncRoute(ctx context.Context, domain, upstream string, tls bool, certDir string) error { _, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "") return err diff --git a/internal/frp/agent_insights.go b/internal/frp/agent_insights.go new file mode 100644 index 0000000..0407635 --- /dev/null +++ b/internal/frp/agent_insights.go @@ -0,0 +1,42 @@ +package frp + +import ( + "context" + "fmt" +) + +// 以下三个方法对应 agent 0.10.0 新增的只读端点;老版本 agent 会返回 +// 「agent 版本过低」的提示(见 AgentClient 的 wrapEndpointMissing),而不是原始 HTTP 错误。 + +// AgentMetrics 返回 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长) +func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + return client.Metrics(ctx) +} + +// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部 +func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind string, lines int) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + if kind != "access" && kind != "error" { + kind = "access" + } + if lines <= 0 || lines > 2000 { + lines = 200 + } + return client.NginxLogs(ctx, kind, lines) +} + +// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对 +func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[string]any, error) { + client, ok := s.agentClient(ctx, serverID) + if !ok { + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") + } + return client.FRPSConfig(ctx) +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 097aff8..dba0066 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index d81e1ce..831ace0 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/sysinfo/sysinfo_linux.go b/internal/sysinfo/sysinfo_linux.go index 44c15b6..0e2f1b5 100644 --- a/internal/sysinfo/sysinfo_linux.go +++ b/internal/sysinfo/sysinfo_linux.go @@ -4,6 +4,8 @@ package sysinfo import ( "os" + "strconv" + "strings" "syscall" ) @@ -22,6 +24,44 @@ func readPlatform() (Stats, CPUSample) { return stats, sample } +// LoadAvg 返回 1 / 5 / 15 分钟平均负载;读取失败返回 nil +func LoadAvg() []float64 { + raw, err := os.ReadFile("/proc/loadavg") + if err != nil { + return nil + } + fields := strings.Fields(string(raw)) + if len(fields) < 3 { + return nil + } + values := make([]float64, 0, 3) + for _, field := range fields[:3] { + value, err := strconv.ParseFloat(field, 64) + if err != nil { + return nil + } + values = append(values, value) + } + return values +} + +// UptimeSeconds 返回系统运行秒数(/proc/uptime) +func UptimeSeconds() uint64 { + raw, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(raw)) + if len(fields) == 0 { + return 0 + } + seconds, err := strconv.ParseFloat(fields[0], 64) + if err != nil || seconds < 0 { + return 0 + } + return uint64(seconds) +} + func diskUsage(path string) (total, used uint64) { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { diff --git a/internal/sysinfo/sysinfo_other.go b/internal/sysinfo/sysinfo_other.go index 7e08f08..c0652b6 100644 --- a/internal/sysinfo/sysinfo_other.go +++ b/internal/sysinfo/sysinfo_other.go @@ -6,3 +6,13 @@ package sysinfo func readPlatform() (Stats, CPUSample) { return Stats{}, CPUSample{} } + +// LoadAvg 未适配的平台没有平均负载概念 +func LoadAvg() []float64 { + return nil +} + +// UptimeSeconds 未适配的平台不采集运行时长 +func UptimeSeconds() uint64 { + return 0 +} diff --git a/internal/sysinfo/sysinfo_windows.go b/internal/sysinfo/sysinfo_windows.go index 45efe35..c76dbc2 100644 --- a/internal/sysinfo/sysinfo_windows.go +++ b/internal/sysinfo/sysinfo_windows.go @@ -13,6 +13,7 @@ var ( procGetSystemTimes = kernel32.NewProc("GetSystemTimes") procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW") + procGetTickCount64 = kernel32.NewProc("GetTickCount64") ) type memoryStatusEx struct { @@ -87,6 +88,17 @@ func diskUsage(root string) (total, used uint64) { return totalBytes, totalBytes - totalFree } +// LoadAvg Windows 没有平均负载概念,固定返回 nil +func LoadAvg() []float64 { + return nil +} + +// UptimeSeconds 用 GetTickCount64 取系统运行秒数 +func UptimeSeconds() uint64 { + ret, _, _ := procGetTickCount64.Call() + return uint64(ret) / 1000 +} + // filetimeTicks 把 FILETIME(100ns 单位)折算为 10ms 刻度,避免累加时溢出 func filetimeTicks(ft syscall.Filetime) uint64 { return (uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)) / 10000 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0b519f4..21f7982 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -290,7 +290,15 @@ export const api = { uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }), getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`), - getFrpAgentRouteDrift: (id: number) => + getFrpAgentMetrics: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/metrics`), + getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) => + request( + `/api/frpmulti/servers/${id}/agent/logs/nginx?type=${type}&lines=${lines}`, + ), + getFrpAgentFRPSConfig: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/frps-config`), + getFrpAgentRouteDrift: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes/drift`), redeployFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 523fdbb..1c148c3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -736,6 +736,35 @@ export interface FrpRouteHealthSummary { failed_servers?: string[] } +export interface FrpAgentMetrics { + cpu_percent: number + mem_used_bytes: number + mem_total_bytes: number + disk_used_bytes: number + disk_total_bytes: number + disk_path?: string + load_avg?: number[] + uptime_seconds?: number + checked_at: string +} + +export interface FrpAgentNginxLogs { + type: 'access' | 'error' + path: string + lines: string[] + truncated: boolean + checked_at: string +} + +export interface FrpAgentFRPSConfig { + path: string + exists: boolean + content: string + truncated?: boolean + mod_time?: string + checked_at: string +} + export interface FrpRouteDriftItem { domain: string kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch' diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index 97e3a98..7647664 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -83,6 +83,26 @@
端口监听
frps 运行所需端口
+ +
+

{{ metricsError }}

+
+
+ {{ row.label }} + {{ row.value }} + {{ row.detail }} + + + +
+
+ 平均负载 {{ metricsLoadText }} + 运行时长 {{ metricsUptimeText }} +
+
+

{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}

+
+
@@ -260,7 +280,7 @@ import PageHeader from '../components/PageHeader.vue' import StatusBadge from '../components/StatusBadge.vue' import { api } from '../api/client' import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types' -import { formatRelativeTime } from '../utils/format' +import { formatBytes, formatRelativeTime, formatUptime } from '../utils/format' import { copyText } from '../utils/clipboard' // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" @@ -435,6 +455,60 @@ async function load() { } catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' } } +// VPS 资源(需 agent ≥ 0.10.0):老版本 agent 没有该端点,错误信息里已带升级提示 +const agentMetrics = ref(null) +const metricsError = ref('') +const metricsLoading = ref(false) + +async function loadMetrics(serverID: number) { + metricsLoading.value = true + try { + agentMetrics.value = await api.getFrpAgentMetrics(serverID) + metricsError.value = '' + } catch (error) { + agentMetrics.value = null + metricsError.value = error instanceof Error ? error.message : 'VPS 资源获取失败' + } finally { + metricsLoading.value = false + } +} + +function usagePercentOf(used?: number, total?: number) { + if (!used || !total || total <= 0) return 0 + return Math.min(100, (used / total) * 100) +} + +const metricsRows = computed(() => { + const data = agentMetrics.value + if (!data) return [] + const rows = [ + { label: 'CPU', percent: Math.min(100, Math.max(0, data.cpu_percent ?? 0)), detail: '整机占用率' }, + { + label: '内存', + percent: usagePercentOf(data.mem_used_bytes, data.mem_total_bytes), + detail: `${formatBytes(data.mem_used_bytes)} / ${formatBytes(data.mem_total_bytes)}`, + }, + { + label: '磁盘', + percent: usagePercentOf(data.disk_used_bytes, data.disk_total_bytes), + detail: `${formatBytes(data.disk_used_bytes)} / ${formatBytes(data.disk_total_bytes)}`, + }, + ] + return rows.map((row) => ({ + ...row, + value: `${Math.round(row.percent)}%`, + tone: row.percent >= 90 ? 'danger' : row.percent >= 75 ? 'warn' : 'ok', + })) +}) + +const metricsLoadText = computed(() => + agentMetrics.value?.load_avg?.length ? agentMetrics.value.load_avg.map((value) => value.toFixed(2)).join(' / ') : '—', +) + +const metricsUptimeText = computed(() => + agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—', +) + // 轮询走静默(不触碰 busy);首次加载显示 statusLoading async function refreshAll(options: { silent?: boolean } = { silent: true }) { const current = server.value @@ -444,6 +518,8 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) { agentStatus.value = await api.getFrpAgentStatus(current.id) agentStatusOk.value = true statusLoaded.value = true + // 资源采集单独走,不阻断状态加载 + void loadMetrics(current.id) } catch (error) { agentStatusOk.value = false statusLoaded.value = true @@ -659,4 +735,88 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) .agent-actions .n-button { min-height: 44px; flex: 1 1 auto; } .entrance-row { flex-direction: column; align-items: flex-start; } } +/* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */ +.vps-resource-card { + grid-column: 1 / -1; +} + +.res-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + align-items: center; + gap: var(--fonu-space-5); +} + +.res-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + 'label value' + 'detail detail' + 'bar bar'; + align-items: center; + gap: var(--fonu-space-2); + font-size: 13px; +} + +.res-row__label { + grid-area: label; + color: var(--fonu-text-secondary); +} + +.res-row__detail { + grid-area: detail; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--fonu-text-muted); +} + +.res-row__value { + grid-area: value; + font-weight: 600; + color: var(--fonu-text); + font-variant-numeric: tabular-nums; +} + +.res-bar { + grid-area: bar; + display: block; + height: 4px; + border-radius: 2px; + background: var(--fonu-border); + overflow: hidden; +} + +.res-bar__fill { + display: block; + height: 100%; + border-radius: 2px; + background: var(--fonu-brand); + transition: width 0.3s ease; +} + +.res-bar--warn .res-bar__fill { + background: var(--fonu-warning); +} + +.res-bar--danger .res-bar__fill { + background: var(--fonu-error); +} + +.res-meta { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: var(--fonu-space-4); + border-left: 1px solid var(--fonu-border); + font-size: 12px; + color: var(--fonu-text-muted); +} + +@media (max-width: 900px) { + .res-strip { grid-template-columns: 1fr; } + .res-meta { padding-left: 0; border-left: none; } +} diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 1891671..9e0b187 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -589,9 +589,17 @@ 已启用的 TCP/UDP 隧道远程端口:{{ tcpRemotePorts.join('、') }} —— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。

+

+ {{ frpsCompare.text }} +

{{ frpsConfigContent || '生成中…' }}
- + @@ -940,6 +948,47 @@ const showServerDetail = ref(false) // frps.toml 同源配置弹窗:按服务端生成,与 frpc 侧端口/认证一致 const showFRPSConfig = ref(false) const frpsConfigContent = ref('') +// frps.toml 与 VPS 实际文件的比对结果(P1-3,需 agent ≥ 0.10.0) +const frpsCompare = ref<{ ok: boolean; text: string } | null>(null) +const comparingFrps = ref(false) + +// 比对前统一去注释、空行与首尾空白:注释和排版差异不算「被手工修改」 +function normalizedConfigLines(text: string) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')) +} + +async function compareFrpsConfig() { + const target = frpsConfigTarget.value + if (!target) return + comparingFrps.value = true + frpsCompare.value = null + try { + const remote = await api.getFrpAgentFRPSConfig(target.id) + if (!remote.exists) { + frpsCompare.value = { ok: false, text: `VPS 上还没有 ${remote.path || 'frps.toml'},可直接下发写入` } + return + } + const expected = normalizedConfigLines(frpsConfigContent.value) + const actual = normalizedConfigLines(remote.content) + const index = expected.findIndex((line, i) => actual[i] !== line) + if (index === -1 && expected.length === actual.length) { + frpsCompare.value = { ok: true, text: `与 VPS 上的 ${remote.path} 一致(已忽略注释与空行)` } + return + } + const at = index === -1 ? Math.min(expected.length, actual.length) : index + frpsCompare.value = { + ok: false, + text: `不一致:第 ${at + 1} 行 下发「${expected[at] ?? '(无)'}」/ VPS「${actual[at] ?? '(无)'}」——VPS 上的 frps.toml 可能被手工改过,重新下发会覆盖`, + } + } catch (error) { + frpsCompare.value = { ok: false, text: error instanceof Error ? error.message : '比对失败' } + } finally { + comparingFrps.value = false + } +} const frpsConfigTarget = ref(null) const frpsConfigLoading = ref(false) const showRuntimeModal = ref(false) @@ -1747,6 +1796,7 @@ async function loadExport() { async function openFRPSConfig(server: FrpServer) { frpsConfigTarget.value = server showFRPSConfig.value = true + frpsCompare.value = null frpsConfigLoading.value = true try { frpsConfigContent.value = (await api.getFrpFRPSConfig(server.id)).content } catch (error) { @@ -2148,4 +2198,7 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); stopDownloadPolling( @media (max-width: 1100px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 700px) { .stats-row, .form-grid, .download-proxy-form, .server-detail__grid, .detail-fields, .detail-stats, .server-status-summary { grid-template-columns: 1fr; } .server-status-summary__item--state { min-height: 0; padding-right: 0; padding-bottom: var(--fonu-space-3); border-right: 0; border-bottom: 1px solid var(--fonu-border); } .status-bars { gap: 3px; } .status-bar { height: 28px; } .binary-row { align-items: flex-start; flex-direction: column; } .release-row { grid-template-columns: 1fr; } .release-progress-placeholder { display: none; } .switch-row span:nth-of-type(2) { margin-left: 0; } } @media (max-width: 760px) { .frp-proxy-modal { display: block; max-height: 92vh; overflow-y: auto; } .frp-proxy-modal .proxy-modal__form { max-height: none; } .frp-proxy-modal .proxy-modal__scroll { overflow: visible; } .frp-proxy-modal .proxy-modal__help { width: auto; border-top: 1px solid var(--fonu-border); border-left: 0; } } +.frps-compare--warn { + color: var(--fonu-warning); +} diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue index 08923f3..30cb5e9 100644 --- a/web/src/views/LogsView.vue +++ b/web/src/views/LogsView.vue @@ -66,6 +66,42 @@ /> + + +
+ + + + + {{ nginxPath }}{{ nginxTruncated ? '(仅显示尾部)' : '' }} + +
+ + + +
+ ([]) +const nginxTargetId = ref(null) +const nginxKind = ref<'access' | 'error'>('access') +const nginxLines = ref(200) +const nginxRaw = ref([]) +const nginxPath = ref('') +const nginxTruncated = ref(false) +const nginxLoading = ref(false) +const nginxError = ref('') +const nginxKeyword = ref('') +const nginxPage = ref(1) +const nginxPageSize = 50 + +const nginxTargetOptions = computed(() => + nginxTargets.value.map((item) => ({ label: item.name || item.agent_url || `服务端 #${item.id}`, value: item.id })), +) + +const nginxKindOptions = [ + { label: '访问日志', value: 'access' }, + { label: '错误日志', value: 'error' }, +] + +const nginxLineOptions = [ + { label: '200 行', value: 200 }, + { label: '500 行', value: 500 }, + { label: '2000 行', value: 2000 }, +] + +const filteredNginxLines = computed(() => { + const rows = nginxRaw.value.map((line) => ({ line })) + const keyword = nginxKeyword.value.trim().toLowerCase() + if (!keyword) return rows + return rows.filter((row) => row.line.toLowerCase().includes(keyword)) +}) + +const pagedNginxLines = computed(() => { + const start = (nginxPage.value - 1) * nginxPageSize + return filteredNginxLines.value.slice(start, start + nginxPageSize) +}) + +const nginxColumns: DataTableColumns<{ line: string }> = [ + { + title: '日志', + key: 'line', + ellipsis: { tooltip: true }, + render: (row) => h('span', { class: 'log-message' }, row.line), + }, +] + +async function loadNginxTargets() { + try { + nginxTargets.value = asList(await api.listFrpServers()).filter((item) => item.agent_configured) + } catch { + nginxTargets.value = [] + } + if (nginxTargetId.value === null || !nginxTargets.value.some((item) => item.id === nginxTargetId.value)) { + nginxTargetId.value = nginxTargets.value[0]?.id ?? null + } +} + +async function loadRemoteNginxLogs() { + if (nginxTargetId.value === null) { + nginxRaw.value = [] + nginxError.value = '没有已配置 Agent 的服务端' + return + } + nginxLoading.value = true + nginxError.value = '' + try { + const result = await api.getFrpAgentNginxLogs(nginxTargetId.value, nginxKind.value, nginxLines.value) + nginxRaw.value = result.lines ?? [] + nginxPath.value = result.path ?? '' + nginxTruncated.value = result.truncated ?? false + nginxPage.value = 1 + } catch (error) { + nginxRaw.value = [] + nginxError.value = error instanceof Error ? error.message : '读取公网 Nginx 日志失败' + } finally { + nginxLoading.value = false + } +} + +watch([nginxTargetId, nginxKind, nginxLines], () => { + nginxPage.value = 1 + if (nginxTargetId.value !== null) void loadRemoteNginxLogs() +}) + +watch(nginxKeyword, () => { + nginxPage.value = 1 +}) + +// 切到本页签时才拉服务端列表与日志,避免影响其它页签的首屏 +watch(tab, (value) => { + if (value === 'remote-nginx' && nginxTargets.value.length === 0) { + void loadNginxTargets().then(() => loadRemoteNginxLogs()) + } +}) + async function loadFrpLogs() { loadingFrp.value = true try { @@ -766,4 +901,22 @@ onUnmounted(() => { .logs-card :deep(.log-module-tag) { white-space: nowrap; } +.remote-nginx-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--fonu-space-3); + margin-bottom: var(--fonu-space-2); +} + +.remote-nginx-bar__server { + width: 200px; +} + +.remote-nginx-bar__path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 420px; +}