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 {{ metricsError }} {{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}
+ {{ 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