feat(agent): agent 0.10.0 新增资源/日志/配置回读端点,前端接入 VPS 资源与公网 Nginx 日志(v1.2.4)
agent 0.10.0:新增 GET /api/v1/metrics(资源采集,复用 internal/sysinfo;CPU 两次采样差值 + 1 秒内复用上次结果)、GET /api/v1/logs/nginx(日志尾部,路径白名单来自 nginx -T / 环境变量 / 默认目录,32KB 块倒读、单行 4KB 截断、上限 2000 行)、GET /api/v1/frps/config(frps.toml 回读,256KB 上限) internal/sysinfo:补 LoadAvg 与 UptimeSeconds(Linux /proc/loadavg 与 /proc/uptime、Windows GetTickCount64、其它平台空值) 应用侧:AgentClient 三个只读方法;request 错误改为带状态码的 statusError,新增 ErrAgentEndpointMissing,把 404/405 统一转成「agent 版本过低需 ≥ 0.10.0」提示;补 service/handler/router 前端:公网 Agent 页「VPS 资源」横排带(占满环境与依赖整行);日志中心「公网 Nginx」页签;frps 同源配置弹窗「比对 VPS 实际文件」 二进制:内嵌 fonu-agent-linux-amd64 / arm64 用新代码重编;VERSION → 1.2.4
This commit is contained in:
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v1.2.3] - 2026-09-24
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fonu/fonu/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
||||||
@@ -31,7 +33,8 @@ import (
|
|||||||
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
||||||
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
||||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
// 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 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -49,6 +52,11 @@ type Config struct {
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
||||||
|
metricsMu sync.Mutex
|
||||||
|
metricsAt time.Time
|
||||||
|
metricsSample sysinfo.CPUSample
|
||||||
|
metricsCached map[string]any
|
||||||
// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
|
// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
|
||||||
nginxStatusMu sync.Mutex
|
nginxStatusMu sync.Mutex
|
||||||
nginxStatusAt time.Time
|
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/install", s.auth(s.installFRPS))
|
||||||
mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
|
mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
|
||||||
mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate))
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/frp"
|
"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)
|
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) {
|
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(r.PathValue("id"))
|
id, err := parseID(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
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("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/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -54,7 +57,7 @@ func (c *AgentClient) request(ctx context.Context, method, path string, payload
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
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 result != nil && len(data) > 0 {
|
||||||
if err := json.Unmarshal(data, result); err != nil {
|
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
|
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 {
|
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, "")
|
_, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "")
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,8 @@ package sysinfo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +24,44 @@ func readPlatform() (Stats, CPUSample) {
|
|||||||
return stats, sample
|
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) {
|
func diskUsage(path string) (total, used uint64) {
|
||||||
var stat syscall.Statfs_t
|
var stat syscall.Statfs_t
|
||||||
if err := syscall.Statfs(path, &stat); err != nil {
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
|||||||
@@ -6,3 +6,13 @@ package sysinfo
|
|||||||
func readPlatform() (Stats, CPUSample) {
|
func readPlatform() (Stats, CPUSample) {
|
||||||
return Stats{}, CPUSample{}
|
return Stats{}, CPUSample{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadAvg 未适配的平台没有平均负载概念
|
||||||
|
func LoadAvg() []float64 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UptimeSeconds 未适配的平台不采集运行时长
|
||||||
|
func UptimeSeconds() uint64 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var (
|
|||||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||||
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
|
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
|
||||||
|
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryStatusEx struct {
|
type memoryStatusEx struct {
|
||||||
@@ -87,6 +88,17 @@ func diskUsage(root string) (total, used uint64) {
|
|||||||
return totalBytes, totalBytes - totalFree
|
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 刻度,避免累加时溢出
|
// filetimeTicks 把 FILETIME(100ns 单位)折算为 10ms 刻度,避免累加时溢出
|
||||||
func filetimeTicks(ft syscall.Filetime) uint64 {
|
func filetimeTicks(ft syscall.Filetime) uint64 {
|
||||||
return (uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)) / 10000
|
return (uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)) / 10000
|
||||||
|
|||||||
@@ -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' }),
|
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' }),
|
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||||
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
||||||
getFrpAgentRouteDrift: (id: number) =>
|
getFrpAgentMetrics: (id: number) =>
|
||||||
|
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
|
||||||
|
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
|
||||||
|
request<import('./types').FrpAgentNginxLogs>(
|
||||||
|
`/api/frpmulti/servers/${id}/agent/logs/nginx?type=${type}&lines=${lines}`,
|
||||||
|
),
|
||||||
|
getFrpAgentFRPSConfig: (id: number) =>
|
||||||
|
request<import('./types').FrpAgentFRPSConfig>(`/api/frpmulti/servers/${id}/agent/frps-config`),
|
||||||
|
getFrpAgentRouteDrift: (id: number) =>
|
||||||
request<import('./types').FrpRouteDriftReport>(`/api/frpmulti/servers/${id}/agent/routes/drift`),
|
request<import('./types').FrpRouteDriftReport>(`/api/frpmulti/servers/${id}/agent/routes/drift`),
|
||||||
redeployFrpAgentRoutes: (id: number) =>
|
redeployFrpAgentRoutes: (id: number) =>
|
||||||
request<import('./types').FrpRedeployAllResult>(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, {
|
request<import('./types').FrpRedeployAllResult>(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, {
|
||||||
|
|||||||
@@ -736,6 +736,35 @@ export interface FrpRouteHealthSummary {
|
|||||||
failed_servers?: string[]
|
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 {
|
export interface FrpRouteDriftItem {
|
||||||
domain: string
|
domain: string
|
||||||
kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch'
|
kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch'
|
||||||
|
|||||||
+161
-1
@@ -83,6 +83,26 @@
|
|||||||
<div class="stat-card__label">端口监听</div>
|
<div class="stat-card__label">端口监听</div>
|
||||||
<div class="stat-card__sub">frps 运行所需端口</div>
|
<div class="stat-card__sub">frps 运行所需端口</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FonuCard class="section-card vps-resource-card" title="VPS 资源">
|
||||||
|
<div class="section-body">
|
||||||
|
<p v-if="metricsError" class="form-hint">{{ metricsError }}</p>
|
||||||
|
<div v-else-if="agentMetrics" class="res-strip">
|
||||||
|
<div v-for="row in metricsRows" :key="row.label" class="res-row">
|
||||||
|
<span class="res-row__label">{{ row.label }}</span>
|
||||||
|
<span class="res-row__value">{{ row.value }}</span>
|
||||||
|
<span class="res-row__detail">{{ row.detail }}</span>
|
||||||
|
<span class="res-bar" :class="`res-bar--${row.tone}`">
|
||||||
|
<span class="res-bar__fill" :style="{ width: `${row.percent.toFixed(1)}%` }" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="res-meta">
|
||||||
|
<span>平均负载 {{ metricsLoadText }}</span>
|
||||||
|
<span>运行时长 {{ metricsUptimeText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="form-hint">{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}</p>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ② 环境与依赖 -->
|
<!-- ② 环境与依赖 -->
|
||||||
@@ -260,7 +280,7 @@ import PageHeader from '../components/PageHeader.vue'
|
|||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
|
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'
|
import { copyText } from '../utils/clipboard'
|
||||||
|
|
||||||
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
|
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
|
||||||
@@ -435,6 +455,60 @@ async function load() {
|
|||||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VPS 资源(需 agent ≥ 0.10.0):老版本 agent 没有该端点,错误信息里已带升级提示
|
||||||
|
const agentMetrics = ref<import('../api/types').FrpAgentMetrics | null>(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
|
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
|
||||||
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
@@ -444,6 +518,8 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
|||||||
agentStatus.value = await api.getFrpAgentStatus(current.id)
|
agentStatus.value = await api.getFrpAgentStatus(current.id)
|
||||||
agentStatusOk.value = true
|
agentStatusOk.value = true
|
||||||
statusLoaded.value = true
|
statusLoaded.value = true
|
||||||
|
// 资源采集单独走,不阻断状态加载
|
||||||
|
void loadMetrics(current.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
agentStatusOk.value = false
|
agentStatusOk.value = false
|
||||||
statusLoaded.value = true
|
statusLoaded.value = true
|
||||||
@@ -659,4 +735,88 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
|||||||
.agent-actions .n-button { min-height: 44px; flex: 1 1 auto; }
|
.agent-actions .n-button { min-height: 44px; flex: 1 1 auto; }
|
||||||
.entrance-row { flex-direction: column; align-items: flex-start; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -589,9 +589,17 @@
|
|||||||
已启用的 TCP/UDP 隧道远程端口:<span class="mono">{{ tcpRemotePorts.join('、') }}</span>
|
已启用的 TCP/UDP 隧道远程端口:<span class="mono">{{ tcpRemotePorts.join('、') }}</span>
|
||||||
—— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。
|
—— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="frpsCompare" class="form-hint" :class="{ 'frps-compare--warn': !frpsCompare.ok }">
|
||||||
|
{{ frpsCompare.text }}
|
||||||
|
</p>
|
||||||
<pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre>
|
<pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer"><n-button @click="showFRPSConfig = false">关闭</n-button></div>
|
<div class="modal-footer">
|
||||||
|
<n-button size="small" :loading="comparingFrps" :disabled="!frpsConfigTarget" @click="compareFrpsConfig">
|
||||||
|
比对 VPS 实际文件
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="showFRPSConfig = false">关闭</n-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
@@ -940,6 +948,47 @@ const showServerDetail = ref(false)
|
|||||||
// frps.toml 同源配置弹窗:按服务端生成,与 frpc 侧端口/认证一致
|
// frps.toml 同源配置弹窗:按服务端生成,与 frpc 侧端口/认证一致
|
||||||
const showFRPSConfig = ref(false)
|
const showFRPSConfig = ref(false)
|
||||||
const frpsConfigContent = ref('')
|
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<FrpServer | null>(null)
|
const frpsConfigTarget = ref<FrpServer | null>(null)
|
||||||
const frpsConfigLoading = ref(false)
|
const frpsConfigLoading = ref(false)
|
||||||
const showRuntimeModal = ref(false)
|
const showRuntimeModal = ref(false)
|
||||||
@@ -1747,6 +1796,7 @@ async function loadExport() {
|
|||||||
async function openFRPSConfig(server: FrpServer) {
|
async function openFRPSConfig(server: FrpServer) {
|
||||||
frpsConfigTarget.value = server
|
frpsConfigTarget.value = server
|
||||||
showFRPSConfig.value = true
|
showFRPSConfig.value = true
|
||||||
|
frpsCompare.value = null
|
||||||
frpsConfigLoading.value = true
|
frpsConfigLoading.value = true
|
||||||
try { frpsConfigContent.value = (await api.getFrpFRPSConfig(server.id)).content }
|
try { frpsConfigContent.value = (await api.getFrpFRPSConfig(server.id)).content }
|
||||||
catch (error) {
|
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: 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: 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; } }
|
@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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+154
-1
@@ -66,6 +66,42 @@
|
|||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="remote-nginx" tab="公网 Nginx">
|
||||||
|
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
|
||||||
|
<div class="remote-nginx-bar">
|
||||||
|
<n-select
|
||||||
|
v-model:value="nginxTargetId"
|
||||||
|
:options="nginxTargetOptions"
|
||||||
|
placeholder="选择服务端"
|
||||||
|
size="small"
|
||||||
|
class="remote-nginx-bar__server"
|
||||||
|
/>
|
||||||
|
<n-select v-model:value="nginxKind" :options="nginxKindOptions" size="small" style="width: 120px" />
|
||||||
|
<n-select v-model:value="nginxLines" :options="nginxLineOptions" size="small" style="width: 110px" />
|
||||||
|
<span v-if="nginxPath" class="tab-hint mono remote-nginx-bar__path">
|
||||||
|
{{ nginxPath }}{{ nginxTruncated ? '(仅显示尾部)' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<n-data-table
|
||||||
|
v-if="filteredNginxLines.length > 0"
|
||||||
|
class="log-table"
|
||||||
|
:columns="nginxColumns"
|
||||||
|
:data="pagedNginxLines"
|
||||||
|
:loading="nginxLoading"
|
||||||
|
:bordered="false"
|
||||||
|
/>
|
||||||
|
<LogPagination
|
||||||
|
v-if="filteredNginxLines.length > 0"
|
||||||
|
v-model:page="nginxPage"
|
||||||
|
:item-count="filteredNginxLines.length"
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
v-if="!nginxLoading && filteredNginxLines.length === 0"
|
||||||
|
title="暂无公网 Nginx 日志"
|
||||||
|
:description="nginxError || 'VPS 上产生访问后,这里会显示 Nginx 的访问 / 错误日志(需 agent ≥ 0.10.0)。'"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
<n-tab-pane name="access" tab="访问日志">
|
<n-tab-pane name="access" tab="访问日志">
|
||||||
<LogToolbar
|
<LogToolbar
|
||||||
v-model:keyword="accessKeyword"
|
v-model:keyword="accessKeyword"
|
||||||
@@ -245,7 +281,7 @@ const LogToolbar = defineComponent({
|
|||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const LOG_TABS = ['system', 'access', 'nginx'] as const
|
const LOG_TABS = ['system', 'frp', 'remote-nginx', 'access', 'nginx'] as const
|
||||||
type LogTab = (typeof LOG_TABS)[number]
|
type LogTab = (typeof LOG_TABS)[number]
|
||||||
|
|
||||||
function resolveTab(queryTab: unknown): LogTab {
|
function resolveTab(queryTab: unknown): LogTab {
|
||||||
@@ -451,6 +487,105 @@ function frpRowClassName(row: FrpLogEntry) {
|
|||||||
return 'log-row'
|
return 'log-row'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
|
||||||
|
const nginxTargets = ref<import('../api/types').FrpServer[]>([])
|
||||||
|
const nginxTargetId = ref<number | null>(null)
|
||||||
|
const nginxKind = ref<'access' | 'error'>('access')
|
||||||
|
const nginxLines = ref(200)
|
||||||
|
const nginxRaw = ref<string[]>([])
|
||||||
|
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() {
|
async function loadFrpLogs() {
|
||||||
loadingFrp.value = true
|
loadingFrp.value = true
|
||||||
try {
|
try {
|
||||||
@@ -766,4 +901,22 @@ onUnmounted(() => {
|
|||||||
.logs-card :deep(.log-module-tag) {
|
.logs-card :deep(.log-module-tag) {
|
||||||
white-space: nowrap;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user