fix(web,agent): P1 正确性与鉴权加固,P2 抽 fsutil 并补测试(v1.2.8)
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-24 19:03:05 +08:00
parent 6abedbbe42
commit 8f75e99339
42 changed files with 1190 additions and 215 deletions
+1 -4
View File
@@ -6,11 +6,8 @@ bin
/data /data
# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文) # 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文)
.data .data
# 已弃用归档代码,不参与编译
internal/frp-1.0.2
web/src/views/FrpView-1.0.2.vue
# 文档与本地杂项 # 文档与本地杂项
docs docs
# 本地导出/打包产物与日志(预防性:不进入构建上下文) # 本地导出/打包产物与日志(预防性:不进入构建上下文)
*.log *.log
*.rar *.rar
+4
View File
@@ -33,6 +33,10 @@ jobs:
working-directory: web working-directory: web
run: npm run build run: npm run build
- name: Run web unit tests
working-directory: web
run: npm test
- name: Sync web dist into embed directory - name: Sync web dist into embed directory
run: | run: |
rm -rf cmd/fonu/web/dist rm -rf cmd/fonu/web/dist
+33
View File
@@ -2,6 +2,39 @@
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.8] - 2026-09-24
### 修复
- 公网服务端:`frps` 启停失败不再假成功——之前忽略 `systemctl` 的 err 后无条件写「frps.service 已启动」,现在失败时返回 `ok:false` 与 stderr,运维不会误以为服务在跑
- frps 配置写入:`.bak` 备份失败时立即中止并删除 `.verify`,不再覆盖唯一副本(旧配置得留退路)
- 安装 frps 后下发同源 `frps.toml` 失败不再静默吞掉:结果里带 `config_warning` 并追加到 `notes`
- 请求体解析:证书申请 / 续期、DDNS 测试改为严格 400;frps 安装允许空请求体(走默认版本)但拒绝 JSON 语法错误
- 会话与登录:同一 IP 在 5 分钟内失败 5 次后返回 429(成功后清零,带过期清理与上限);改密码只失效该账号的**其他**会话,不再把当前会话一并踢掉;会话 cookie 按 `r.TLS` / `X-Forwarded-Proto` 决定是否加 `Secure`(默认明文部署不加,否则浏览器丢 cookie 导致登不进去),`clearSessionCookie` 补 `SameSite=Lax`
- 后台下载改为继承应用 ctx(新增 `Service.SetBaseContext`),App 关闭时随之中止;`scheduler` 每个任务包 `recover()`,单个任务 panic 不再带走整个进程,且建表时跳过非正间隔(`time.NewTicker` 会 panic)
- 两个 `http.Server` 补 `ReadTimeout` / `IdleTimeout`(agent 60s/2min;应用 5min/2min,**不设 `WriteTimeout`**——否则日志 SSE 会被中途切断)
- 公网反代页:`refreshAll` 的 7 个独立接口由串行 `await` 改为 `Promise.all`,每次刷新不再等于 7 个往返相加
- 内网穿透页:删除 74 条随服务端 UI 迁走的死样式;公网服务端页合并逐字重复的样式块
- 文档与工程:修正 README / DEPLOYMENT / `frps_template.go` 中「Docker 镜像内置 frpc 0.71.0」的不准确说法(镜像不预装、按需下载);`.dockerignore` 删掉两行已失效的归档排除项并修掉 ` docs` 的前导空格
### 改进
- 新增 `internal/fsutil`:`WriteFileAtomic`(同目录随机名临时文件 + rename + 显式 chmod)与 `Backup`(文件不存在视为无需备份),收敛 agent / frp / nginx / chinacidr 各处自建的 `.tmp` 与 `.bak` 逻辑(其中 nginx 原来用固定 `.tmp` 名,并发写同一路径会互相踩)
- 流量采集:`notify.RecordAccessHit`(写库 + 判断通知)从写锁内移到锁外,不再顶住 `/api/proxies/traffic` 与 `/clients` 的读
- `internal/frp` 的 agent 客户端改为按 `serverID` 缓存(指纹 = `AgentURL` + Token 密文),批量同步 N 条路由不再新建 N 个连接池与 TLS 握手
- 前端新增 `useVisibilityPolling()`:标签页隐藏时暂停轮询、回前台立即补一次;公网反代页与内网穿透页已接入,仪表盘流量轮询加 in-flight 互斥避免堆积并发请求
- 新增 `docs/README.md` 文档索引;给 `agent-capability-plan` / `agent-view-design` / `-v2` / `upstream-sync-plan` 加实施状态与时效说明
### 测试
- Go 新增 4 个测试文件:`backup`(`../` 逃逸与 `../data-other/` 同前缀绕过、还原权限固定)、`scheduler`(非正间隔跳过、panic 后继续)、`frp`(`shellPath` 单引号转义、`isELFBinary`)、`agent`(`validateRoute` 表驱动、`nginxRoute` 关键行、`fileSHA256`)
- 前端引入 vitest(`npm test` = `vitest run`),新增 13 个单测覆盖 `utils/format.ts` 与 `utils/status.ts`(含一条守住「n-tag 的 type 值不能当状态值」的回归用例);CI 增加「Run web unit tests」一步
### 变更
- `AgentVersion` 0.12.0 → **0.12.1**(frps 启停失败如实回报、HTTP 服务补超时);内嵌 `fonu-agent-linux-amd64` 与 `-arm64` 已重编,VPS 上需点「升级 Agent」才会生效
- 版本号提升到 1.2.8(版本单一来源仍是根目录 `VERSION`);`web/package.json` 的版本同步对齐,本地构建不再显示 1.0.2
## [v1.2.7] - 2026-09-24 ## [v1.2.7] - 2026-09-24
### 修复 ### 修复
+1 -1
View File
@@ -153,7 +153,7 @@ docker compose up -d --build
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 | | `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 | | `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) | | `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) |
| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(Docker 镜像内置 0.71.0) | | `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(镜像不预装,用「应用配置」按需下载或自行放置) |
| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 | | `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 |
## 注意事项 ## 注意事项
+1 -1
View File
@@ -1 +1 @@
1.2.7 1.2.8
+2 -2
View File
@@ -39,7 +39,7 @@
| Docker | 20.10+(含 compose v2) | | Docker | 20.10+(含 compose v2) |
| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) | | 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) |
| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) | | 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) |
| 网络 | 构建期需访问 npm registry、Go module proxy、GitHub(下载 frpc 0.71.0);运行期需访问你的 DNS 服务商 API(DDNS/证书) | | 网络 | 构建期需访问 npm registry 与 Go module proxy;运行期需访问 GitHub(按需下载 frpc)与你的 DNS 服务商 API(DDNS/证书) |
| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) | | 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) |
## 2. 构建镜像(所有部署方式的前提) ## 2. 构建镜像(所有部署方式的前提)
@@ -50,7 +50,7 @@
docker compose build docker compose build
``` ```
一次构建同时产出:前端 `dist`、Go 后端二进制、内置 frpc 0.71.0 的运行镜像,标签为 `fonu:local`。 一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `fonu:local`。
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 **版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
+29
View File
@@ -0,0 +1,29 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-24(v1.2.7)
## 方案与设计(本目录)
| 文件 | 主题 | 状态 |
| --- | --- | --- |
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 |
| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 |
| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 |
| `proxy-remote-design.md` | 公网反代页设计方案(以本地反代页为参照) | 已实施;列表已改为左服务端 + 右台账 |
| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 |
| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 |
| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 |
| `frps服务端管理方案.md` | FRPS 服务端管理方案(fonu-agent 部署在公网服务器) | 已实施(Agent 管理面板) |
| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) |
| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 |
| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 |
| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `fonu-1.1.0` 安装包 |
## 其它位置
- 仓库根 `../docs/`(`111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`)为**临时区**,不随本项目维护;正式文档一律放本目录。
- `screenshots/` 供 README 引用。
+2
View File
@@ -1,5 +1,7 @@
# fonu-agent 能力扩展方案 # fonu-agent 能力扩展方案
> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。
> 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。 > 本文盘点 VPS 侧 `fonu-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。
> >
> 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。 > 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。
+2
View File
@@ -1,5 +1,7 @@
# 公网 Agent 页(`AgentView.vue`)设计方案 v2 # 公网 Agent 页(`AgentView.vue`)设计方案 v2
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**: > 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。 > 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
> >
+2
View File
@@ -1,5 +1,7 @@
# 公网 Agent 页(`AgentView.vue`)设计方案 # 公网 Agent 页(`AgentView.vue`)设计方案
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**, > 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
> 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。 > 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
> >
+2
View File
@@ -1,5 +1,7 @@
# 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项 # 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项
> **时效说明(2026-09-24)**:文中对 `/frp` 页现状的描述基于 2026-09-23,已被 v1.2.6 的重构推翻(现为**左服务端列表 + 右规则表格**,服务端增删改已并入 `/frp-agent`)。
> 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。 > 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**, > 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。 > 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
+65
View File
@@ -0,0 +1,65 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateRoute(t *testing.T) {
cases := []struct {
name string
req routeRequest
ok bool
}{
{"正常", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, true},
{"非法域名", routeRequest{Domain: "bad domain", Upstream: "127.0.0.1:8080"}, false},
{"空上游", routeRequest{Domain: "a.example.com"}, false},
{"上游带分号", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80; rm -rf /"}, false},
{"相对证书目录", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80", TLS: true, CertDir: "certs"}, false},
}
for _, c := range cases {
err := validateRoute(c.req)
if c.ok && err != nil {
t.Errorf("%s:期望通过,实际 %v", c.name, err)
}
if !c.ok && err == nil {
t.Errorf("%s:期望报错", c.name)
}
}
}
// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/fonu-agent 写 htpasswd,不适合在单测里触发。
func TestNginxRouteRendersUpstream(t *testing.T) {
out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "")
if !strings.HasPrefix(out, "# Managed by fonu-agent.") {
t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out)
}
if !strings.Contains(out, "server_name a.example.com;") {
t.Errorf("缺少 server_name:\n%s", out)
}
if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080;") {
t.Errorf("缺少 proxy_pass 回源:\n%s", out)
}
if strings.Contains(out, "listen 443") {
t.Errorf("未启用 TLS 时不应监听 443:\n%s", out)
}
}
// TestFileSHA256 覆盖 frps 安装包的校验路径。
func TestFileSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "payload.txt")
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
t.Fatal(err)
}
got, err := fileSHA256(path)
if err != nil {
t.Fatal(err)
}
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Fatalf("sha256 不符:got %s", got)
}
}
+21 -27
View File
@@ -25,6 +25,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/sysinfo" "github.com/fonu/fonu/internal/sysinfo"
) )
@@ -38,7 +39,8 @@ import (
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。 // 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进 // 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。 // nginx 配置注释(改为只在 PUT 响应里回一次)。
const AgentVersion = "0.12.0" // 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
const AgentVersion = "0.12.1"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { type Config struct {
@@ -181,7 +183,7 @@ func (s *Server) Handler() http.Handler {
} }
func (s *Server) ListenAndServe(ctx context.Context) error { func (s *Server) ListenAndServe(ctx context.Context) error {
server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, IdleTimeout: 2 * time.Minute}
go func() { go func() {
<-ctx.Done() <-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -341,18 +343,27 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
_ = exec.Command("systemctl", "daemon-reload").Run() _ = exec.Command("systemctl", "daemon-reload").Run()
} }
// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制 // nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil { active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
if exec.Command("pgrep", "-x", "frps").Run() == nil { if !active && exec.Command("pgrep", "-x", "frps").Run() == nil {
_ = exec.Command("pkill", "-x", "frps").Run() _ = exec.Command("pkill", "-x", "frps").Run()
time.Sleep(time.Second) time.Sleep(time.Second)
notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程") notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
} }
_ = exec.Command("systemctl", "enable", "--now", "frps").Run() args := []string{"restart", "frps"}
notes = append(notes, "frps.service 已启动") label := "已重启以加载新版本"
} else { if !active {
_ = exec.Command("systemctl", "restart", "frps").Run() args = []string{"enable", "--now", "frps"}
notes = append(notes, "frps.service 已重启以加载新版本") label = "已启动"
} }
// 启动失败必须如实回报:之前忽略 err 后无条件写「已启动」,运维会以为服务在跑
if out, err := exec.Command("systemctl", args...).CombinedOutput(); err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false, "version": version, "binary": installPath, "notes": notes,
"error": fmt.Sprintf("frps 二进制已安装,但 systemctl %s 失败:%v:%s", strings.Join(args, " "), err, tailText(string(out), 400)),
})
return
}
notes = append(notes, "frps.service "+label)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes})
} }
@@ -1511,24 +1522,7 @@ func atomicWrite(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err return err
} }
tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*") return fsutil.WriteFileAtomic(path, data, mode)
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
} }
func writeJSON(w http.ResponseWriter, status int, value any) { func writeJSON(w http.ResponseWriter, status int, value any) {
+84 -7
View File
@@ -6,6 +6,7 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/fonu/fonu/internal/auth" "github.com/fonu/fonu/internal/auth"
@@ -15,10 +16,67 @@ import (
type AuthHandler struct { type AuthHandler struct {
auth *auth.Service auth *auth.Service
notify *notify.Service notify *notify.Service
guard *loginGuard
} }
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler { func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
return &AuthHandler{auth: authSvc, notify: notifySvc} return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
}
const (
loginFailureLimit = 5
loginLockoutWindow = 5 * time.Minute
)
// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝,
// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。
type loginGuard struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginGuard() *loginGuard {
return &loginGuard{failures: make(map[string][]time.Time)}
}
// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。
func (g *loginGuard) blocked(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
kept := g.recentLocked(ip)
g.failures[ip] = kept
return len(kept) >= loginFailureLimit
}
func (g *loginGuard) fail(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
if len(g.failures) > 1024 {
for key := range g.failures {
if len(g.recentLocked(key)) == 0 {
delete(g.failures, key)
}
}
}
g.failures[ip] = append(g.recentLocked(ip), time.Now())
}
func (g *loginGuard) reset(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.failures, ip)
}
// recentLocked 返回窗口内的失败时间(调用方需持有锁)。
func (g *loginGuard) recentLocked(ip string) []time.Time {
now := time.Now()
kept := make([]time.Time, 0, len(g.failures[ip]))
for _, at := range g.failures[ip] {
if now.Sub(at) < loginLockoutWindow {
kept = append(kept, at)
}
}
return kept
} }
type loginRequest struct { type loginRequest struct {
@@ -55,11 +113,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "请求格式无效") writeError(r, w, http.StatusBadRequest, "请求格式无效")
return return
} }
ip := clientIP(r)
if h.guard.blocked(ip) {
writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password) sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
if err != nil { if err != nil {
if errors.Is(err, auth.ErrUnauthorized) { if errors.Is(err, auth.ErrUnauthorized) {
h.guard.fail(ip)
if h.notify != nil { if h.notify != nil {
h.notify.RecordLoginFailure(r.Context(), clientIP(r)) h.notify.RecordLoginFailure(r.Context(), ip)
} }
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误") writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
return return
@@ -67,7 +131,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusInternalServerError, "登录失败") writeError(r, w, http.StatusInternalServerError, "登录失败")
return return
} }
setSessionCookie(w, sessionID) h.guard.reset(ip)
setSessionCookie(w, r, sessionID)
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"}) writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
} }
@@ -86,7 +151,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期") writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return return
} }
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil { if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error()) writeError(r, w, http.StatusBadRequest, err.Error())
return return
} }
@@ -97,21 +162,31 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" { if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
_ = h.auth.Logout(r.Context(), sessionID) _ = h.auth.Logout(r.Context(), sessionID)
} }
clearSessionCookie(w) clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"}) writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
} }
func setSessionCookie(w http.ResponseWriter, sessionID string) { func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "fonu_session", Name: "fonu_session",
Value: sessionID, Value: sessionID,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: int((7 * 24 * time.Hour).Seconds()), MaxAge: int((7 * 24 * time.Hour).Seconds()),
}) })
} }
// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure;
// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
@@ -120,12 +195,14 @@ func clientIP(r *http.Request) string {
return host return host
} }
func clearSessionCookie(w http.ResponseWriter) { func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "fonu_session", Name: "fonu_session",
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1, MaxAge: -1,
}) })
} }
+8 -2
View File
@@ -44,7 +44,10 @@ func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
CA string `json:"ca"` CA string `json:"ca"`
Email string `json:"email"` Email string `json:"email"`
} }
_ = json.NewDecoder(r.Body).Decode(&req) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
domains := req.Domains domains := req.Domains
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" { if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
domains = []string{req.Domain} domains = []string{req.Domain}
@@ -141,7 +144,10 @@ func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
Domain string `json:"domain"` Domain string `json:"domain"`
CA string `json:"ca"` CA string `json:"ca"`
} }
_ = json.NewDecoder(r.Body).Decode(&req) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA) record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
if err != nil { if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error()) writeError(r, w, http.StatusBadRequest, err.Error())
+4 -1
View File
@@ -119,7 +119,10 @@ func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
APITokenID string `json:"api_token_id"` APITokenID string `json:"api_token_id"`
APISecret string `json:"api_secret"` APISecret string `json:"api_secret"`
} }
_ = json.NewDecoder(r.Body).Decode(&req) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.svc.Test(r.Context(), ddns.TestInput{ if err := h.svc.Test(r.Context(), ddns.TestInput{
ConfigID: req.ConfigID, ConfigID: req.ConfigID,
Provider: req.Provider, Provider: req.Provider,
+7 -1
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -606,7 +608,11 @@ func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Requ
Version string `json:"version"` Version string `json:"version"`
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
} }
_ = json.NewDecoder(r.Body).Decode(&body) // 允许空请求体(走默认版本),但 JSON 语法错误要报 400
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if body.Version == "" { if body.Version == "" {
body.Version = "0.71.0" body.Version = "0.71.0"
} }
+4
View File
@@ -123,9 +123,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: handler, Handler: handler,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
ReadTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval) intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 5 intervalMinutes = 5
+6 -2
View File
@@ -118,7 +118,7 @@ func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int,
return adminID, nil return adminID, nil
} }
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error { func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
if err := validate.Password(newPassword); err != nil { if err := validate.Password(newPassword); err != nil {
return err return err
} }
@@ -134,7 +134,11 @@ func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword,
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID) if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil {
return err
}
// 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线)
_, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID)
return err return err
} }
+90
View File
@@ -0,0 +1,90 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
// writeArchive 造一个含指定条目的 tar.gz,用于验证还原侧的路径校验与权限处理。
func writeArchive(t *testing.T, entries map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "backup.tar.gz")
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gz := gzip.NewWriter(file)
tw := tar.NewWriter(gz)
for name, content := range entries {
header := &tar.Header{Name: name, Mode: 0o777, Size: int64(len(content)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return path
}
// TestRestoreRejectsEscapingEntries 覆盖「同前缀绕过」:旧实现用 strings.HasPrefix 判断,
// DataDir=/data 时 ../data-other/x 会通过前缀匹配写到 DataDir 之外。
func TestRestoreRejectsEscapingEntries(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatal(err)
}
archive := writeArchive(t, map[string]string{"../data-other/evil.txt": "x"})
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
t.Fatal("应当拒绝逃出 DataDir 的条目")
}
if _, err := os.Stat(filepath.Join(root, "data-other", "evil.txt")); err == nil {
t.Fatal("逃逸条目被写到了 DataDir 之外")
}
}
func TestRestoreRejectsParentEscape(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatal(err)
}
archive := writeArchive(t, map[string]string{"../../evil.txt": "x"})
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
t.Fatal("应当拒绝带 .. 的条目")
}
}
// TestRestoreAppliesFixedMode:归档里的权限位不照搬(构造的归档可带 0777 / 执行位)。
func TestRestoreAppliesFixedMode(t *testing.T) {
dataDir := t.TempDir()
archive := writeArchive(t, map[string]string{"nginx/nginx.conf": "worker_processes 1;"})
if err := New(dataDir).Restore(context.Background(), archive); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dataDir, "nginx", "nginx.conf"))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); runtime.GOOS == "windows" {
// Windows 不保留 Unix 权限位(可写文件统一报 0666):只断言没带上执行位
if got&0o111 != 0 {
t.Fatalf("还原后的文件不应带执行位,实际 %o", got)
}
} else if got != restoreFileMode {
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/nginx" "github.com/fonu/fonu/internal/nginx"
"github.com/fonu/fonu/internal/proxy" "github.com/fonu/fonu/internal/proxy"
"github.com/fonu/fonu/internal/settings" "github.com/fonu/fonu/internal/settings"
@@ -96,7 +97,7 @@ func (s *Service) Update(ctx context.Context) error {
} }
tmpPath := s.cfg.ChinaCIDRTempPath() tmpPath := s.cfg.ChinaCIDRTempPath()
if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil { if err := fsutil.WriteFileAtomic(tmpPath, []byte(body), 0o644); err != nil {
return err return err
} }
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -42,7 +42,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
var b strings.Builder var b strings.Builder
b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n") b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;INI 自 v0.52.0 起已被官方弃用)\n") b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(INI 自 v0.52.0 起已被官方弃用)\n")
b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n") b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表 // TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
b.WriteString("bindAddr = \"0.0.0.0\"\n") b.WriteString("bindAddr = \"0.0.0.0\"\n")
+59 -11
View File
@@ -14,6 +14,7 @@ import (
"github.com/fonu/fonu/internal/certificate" "github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/secret" "github.com/fonu/fonu/internal/secret"
) )
@@ -39,9 +40,15 @@ type Service struct {
downloadMu sync.RWMutex downloadMu sync.RWMutex
downloadTask DownloadTask downloadTask DownloadTask
// baseCtx 是应用级 ctx:后台下载随之取消(之前用 context.Background(),App 关闭后仍在跑)
baseCtx context.Context
locationMu sync.Mutex locationMu sync.Mutex
locations map[string]locationCache locations map[string]locationCache
// agentClients 缓存每台服务端的 agent HTTP 客户端(含连接池),按 URL+Token 指纹失效
agentMu sync.Mutex
agentClients map[int64]cachedAgentClient
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测 // 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
diagMu sync.Mutex diagMu sync.Mutex
diagCache map[int64]diagnosticsCacheEntry diagCache map[int64]diagnosticsCacheEntry
@@ -167,6 +174,13 @@ func (s *Service) StartDownload(version string) (DownloadTask, error) {
return s.downloadTask, nil return s.downloadTask, nil
} }
// SetBaseContext 注入应用级 ctx,供后台下载继承;由 app 启动时调用。
func (s *Service) SetBaseContext(ctx context.Context) {
s.downloadMu.Lock()
defer s.downloadMu.Unlock()
s.baseCtx = ctx
}
func (s *Service) DownloadStatus() DownloadTask { func (s *Service) DownloadStatus() DownloadTask {
s.downloadMu.RLock() s.downloadMu.RLock()
defer s.downloadMu.RUnlock() defer s.downloadMu.RUnlock()
@@ -174,7 +188,13 @@ func (s *Service) DownloadStatus() DownloadTask {
} }
func (s *Service) runDownload(version string) { func (s *Service) runDownload(version string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) s.downloadMu.RLock()
base := s.baseCtx
s.downloadMu.RUnlock()
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, 10*time.Minute)
defer cancel() defer cancel()
binary, err := s.downloadRelease(ctx, version, s.updateDownload) binary, err := s.downloadRelease(ctx, version, s.updateDownload)
if err != nil { if err != nil {
@@ -772,10 +792,20 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置 // 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置。
if server, err := s.store.GetServer(ctx, serverID); err == nil { // 失败不能吞掉:否则前端只看到「安装成功」,VPS 上跑的仍是旧配置。
if content, err := s.buildFRPSServerConfig(ctx, server); err == nil { warning := ""
_ = client.PushFRPSConfig(ctx, content) if server, err := s.store.GetServer(ctx, serverID); err != nil {
warning = "frps 已安装,但读取服务端配置失败:" + err.Error()
} else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil {
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
} else if err := client.PushFRPSConfig(ctx, content); err != nil {
warning = "frps 已安装,但 frps.toml 下发失败:" + err.Error()
}
if warning != "" {
result["config_warning"] = warning
if list, ok := result["notes"].([]any); ok {
result["notes"] = append(list, warning)
} }
} }
return result, nil return result, nil
@@ -1259,6 +1289,12 @@ func (s *Service) DeleteProxy(ctx context.Context, id int64) error {
return nil return nil
} }
// cachedAgentClient 是按 URL+Token 指纹缓存的 agent 客户端;指纹用密文而非明文,避免内存里多存一份 Token。
type cachedAgentClient struct {
fingerprint string
client *AgentClient
}
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) { func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID) server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured { if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured {
@@ -1272,7 +1308,19 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
if err != nil || token == "" { if err != nil || token == "" {
return nil, false return nil, false
} }
return NewAgentClient(server.AgentURL, token), true // 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := server.AgentURL + "|" + encoded
s.agentMu.Lock()
defer s.agentMu.Unlock()
if s.agentClients == nil {
s.agentClients = make(map[int64]cachedAgentClient)
}
if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
return cached.client, true
}
client := NewAgentClient(server.AgentURL, token)
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
return client, true
} }
func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) { func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
@@ -1494,8 +1542,10 @@ func (s *Service) persistServerConfigForVerify(ctx context.Context, server Serve
_ = os.Remove(officialPath + ".verify") _ = os.Remove(officialPath + ".verify")
return err return err
} }
if current, readErr := os.ReadFile(officialPath); readErr == nil { // 备份失败就不能覆盖唯一副本:否则旧配置没有回退、只剩 .verify 里的新内容
_ = os.WriteFile(officialPath+".bak", current, 0o600) if err := fsutil.Backup(officialPath); err != nil {
_ = os.Remove(officialPath + ".verify")
return fmt.Errorf("备份现有 frps 配置失败,已保留原配置: %w", err)
} }
return os.Rename(officialPath+".verify", officialPath) return os.Rename(officialPath+".verify", officialPath)
} }
@@ -1820,11 +1870,9 @@ func writeConfigAtomically(path, content string) error {
} }
// 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename), // 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename),
// 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。 // 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。
if current, err := os.ReadFile(path); err == nil { if err := fsutil.Backup(path); err != nil {
if err := os.WriteFile(path+".bak", current, 0o600); err != nil {
return err return err
} }
}
tmp, err := os.CreateTemp(dir, "frpc-*.toml") tmp, err := os.CreateTemp(dir, "frpc-*.toml")
if err != nil { if err != nil {
return err return err
+32
View File
@@ -0,0 +1,32 @@
package frp
import "testing"
// TestShellPathEscapesSingleQuotes:uploadBytes 把路径拼进远程 shell 命令,
// 单引号必须转义,否则路径里的引号能截断命令。
func TestShellPathEscapesSingleQuotes(t *testing.T) {
cases := []struct{ in, want string }{
{"/tmp/fonu-agent", "'/tmp/fonu-agent'"},
{"/tmp/it's here", `'/tmp/it'\''s here'`},
{"", "''"},
}
for _, c := range cases {
if got := shellPath(c.in); got != c.want {
t.Errorf("shellPath(%q) = %s,期望 %s", c.in, got, c.want)
}
}
}
// TestIsELFBinary:内嵌二进制在开发构建里是占位文件,必须识别出来并提前报错,
// 否则 VPS 上 systemd 会反复 Exec format error。
func TestIsELFBinary(t *testing.T) {
if isELFBinary([]byte("not an elf")) {
t.Error("普通文本不应被判为 ELF")
}
if isELFBinary(nil) {
t.Error("空内容不应被判为 ELF")
}
if !isELFBinary([]byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}) {
t.Error("ELF 魔数应被识别")
}
}
+55
View File
@@ -0,0 +1,55 @@
// Package fsutil 收敛「原子写配置」与「覆盖前备份」两个到处重复的动作。
//
// 之前 agent、frp、nginx、chinacidr 各自实现了一遍:有的用固定 .tmp 名(并发写同一路径会互相踩),
// 有的直接 os.WriteFile(读到写了一半的配置),有的写完不 chmod(权限跟着 umask 走)。
package fsutil
import (
"os"
"path/filepath"
)
// WriteFileAtomic 先写同目录的临时文件再 rename:读者要么看到旧内容、要么看到新内容。
// 临时文件名带随机后缀,避免并发写同一路径时互相覆盖。
func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
if tmpName != "" {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// CreateTemp 建出来的是 0600,这里显式对齐调用方要求的权限
if err := os.Chmod(tmpName, mode); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
tmpName = "" // rename 成功,不用再清理
return nil
}
// Backup 把现有文件复制成 <path>.bak;文件不存在视为无需备份。
// 返回的错误必须由调用方处理——备份是「覆盖前留退路」的唯一保障。
func Backup(path string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
return WriteFileAtomic(path+".bak", data, 0o600)
}
+71
View File
@@ -0,0 +1,71 @@
package fsutil
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileAtomicReplacesContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "frps.toml")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := WriteFileAtomic(path, []byte("new"), 0o640); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "new" {
t.Fatalf("内容未替换:%q", data)
}
}
// TestWriteFileAtomicLeavesNoTempFiles:临时文件必须被 rename 或清理,不能在配置目录里留垃圾。
func TestWriteFileAtomicLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
if err := WriteFileAtomic(path, []byte("worker_processes 1;"), 0o600); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.Contains(entry.Name(), ".tmp-") {
t.Fatalf("残留临时文件:%s", entry.Name())
}
}
if len(entries) != 1 {
t.Fatalf("目录里应只剩目标文件,实际 %d 个", len(entries))
}
}
func TestBackupSkipsMissingFile(t *testing.T) {
if err := Backup(filepath.Join(t.TempDir(), "nope.conf")); err != nil {
t.Fatalf("文件不存在时应视为无需备份,实际 %v", err)
}
}
func TestBackupCopiesContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "frpc.toml")
if err := os.WriteFile(path, []byte("token = \"x\""), 0o600); err != nil {
t.Fatal(err)
}
if err := Backup(path); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path + ".bak")
if err != nil {
t.Fatal(err)
}
if string(data) != "token = \"x\"" {
t.Fatalf("备份内容不符:%q", data)
}
}
+4 -3
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
) )
const maxCustomBackups = 10 const maxCustomBackups = 10
@@ -141,21 +142,21 @@ func RestoreBackup(backupDir, backupName, targetPath string) error {
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(targetPath, data, 0o644) return fsutil.WriteFileAtomic(targetPath, data, 0o644)
} }
func WriteGlobalCustom(cfg config.Config, content string) error { func WriteGlobalCustom(cfg config.Config, content string) error {
if err := EnsureCustomDirs(cfg); err != nil { if err := EnsureCustomDirs(cfg); err != nil {
return err return err
} }
return os.WriteFile(cfg.NginxGlobalCustomPath(), []byte(content), 0o644) return fsutil.WriteFileAtomic(cfg.NginxGlobalCustomPath(), []byte(content), 0o644)
} }
func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error { func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error {
if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil { if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil {
return err return err
} }
return os.WriteFile(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644) return fsutil.WriteFileAtomic(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644)
} }
func RemoveRuleCustom(cfg config.Config, ruleID int64) error { func RemoveRuleCustom(cfg config.Config, ruleID int64) error {
+4 -5
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/proxy" "github.com/fonu/fonu/internal/proxy"
) )
@@ -112,7 +113,7 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
} }
tmpPath := m.cfg.NginxConfigPath() + ".tmp" tmpPath := m.cfg.NginxConfigPath() + ".tmp"
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { if err := fsutil.WriteFileAtomic(tmpPath, []byte(content), 0o644); err != nil {
return ApplyResult{}, err return ApplyResult{}, err
} }
@@ -130,13 +131,11 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
} }
currentPath := m.cfg.NginxConfigPath() currentPath := m.cfg.NginxConfigPath()
backupPath := currentPath + ".bak" // 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
if _, err := os.Stat(currentPath); err == nil { if err := fsutil.Backup(currentPath); err != nil {
if err := copyFile(currentPath, backupPath); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return ApplyResult{}, err return ApplyResult{}, err
} }
}
if err := os.Rename(tmpPath, currentPath); err != nil { if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
+21 -2
View File
@@ -2,6 +2,7 @@ package scheduler
import ( import (
"context" "context"
"log"
"time" "time"
) )
@@ -15,8 +16,17 @@ type Scheduler struct {
jobs []Job jobs []Job
} }
// New 会丢掉间隔非正的任务:time.NewTicker 对非正间隔直接 panic,这类任务应由调用方单独启动。
func New(jobs ...Job) *Scheduler { func New(jobs ...Job) *Scheduler {
return &Scheduler{jobs: jobs} valid := make([]Job, 0, len(jobs))
for _, job := range jobs {
if job.Interval <= 0 {
log.Printf("scheduler: 任务 %s 间隔为 %v,已跳过", job.Name, job.Interval)
continue
}
valid = append(valid, job)
}
return &Scheduler{jobs: valid}
} }
func (s *Scheduler) Start(ctx context.Context) { func (s *Scheduler) Start(ctx context.Context) {
@@ -28,13 +38,22 @@ func (s *Scheduler) Start(ctx context.Context) {
func (s *Scheduler) runJob(ctx context.Context, job Job) { func (s *Scheduler) runJob(ctx context.Context, job Job) {
ticker := time.NewTicker(job.Interval) ticker := time.NewTicker(job.Interval)
defer ticker.Stop() defer ticker.Stop()
// 单个任务 panic 不能带走整个进程:包一层 recover,记录后继续下一轮
run := func() {
defer func() {
if r := recover(); r != nil {
log.Printf("scheduler: 任务 %s panic: %v", job.Name, r)
}
}()
job.Run(ctx) job.Run(ctx)
}
run()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
job.Run(ctx) run()
} }
} }
} }
+40
View File
@@ -0,0 +1,40 @@
package scheduler
import (
"context"
"sync/atomic"
"testing"
"time"
)
// TestNewSkipsNonPositiveIntervals:time.NewTicker 对非正间隔会 panic,这类任务必须在建表时丢掉。
func TestNewSkipsNonPositiveIntervals(t *testing.T) {
s := New(
Job{Name: "zero", Interval: 0, Run: func(context.Context) {}},
Job{Name: "negative", Interval: -time.Second, Run: func(context.Context) {}},
Job{Name: "ok", Interval: time.Hour, Run: func(context.Context) {}},
)
if len(s.jobs) != 1 || s.jobs[0].Name != "ok" {
t.Fatalf("非正间隔的任务应被跳过,实际保留 %d 个", len(s.jobs))
}
}
// TestRunJobRecoversFromPanic:单个任务 panic 不能带走整个进程,且要能继续下一轮。
func TestRunJobRecoversFromPanic(t *testing.T) {
var calls int32
s := New(Job{Name: "panic", Interval: 5 * time.Millisecond, Run: func(context.Context) {
atomic.AddInt32(&calls, 1)
panic("boom")
}})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.Start(ctx)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&calls) < 3 {
time.Sleep(5 * time.Millisecond)
}
if got := atomic.LoadInt32(&calls); got < 3 {
t.Fatalf("panic 后应继续后续轮次,实际只执行了 %d 次", got)
}
}
+6 -3
View File
@@ -176,6 +176,7 @@ func (c *Collector) ingestLine(line string, trackLive bool) {
} }
now := time.Now() now := time.Now()
hitIP := ""
c.mu.Lock() c.mu.Lock()
st := c.ensureHost(host) st := c.ensureHost(host)
st.uploadTotal += up st.uploadTotal += up
@@ -189,13 +190,15 @@ func (c *Collector) ingestLine(line string, trackLive bool) {
} }
if entry.ClientIP != "" && entry.ClientIP != "-" { if entry.ClientIP != "" && entry.ClientIP != "-" {
st.clients[entry.ClientIP] = now st.clients[entry.ClientIP] = now
if c.notify != nil { // 锁内只记 IP:RecordAccessHit 会写库并判断通知,放在锁外,避免阻塞读侧
c.notify.RecordAccessHit(context.Background(), entry.ClientIP) hitIP = entry.ClientIP
}
} }
c.pruneHost(st, now) c.pruneHost(st, now)
} }
c.mu.Unlock() c.mu.Unlock()
if hitIP != "" && c.notify != nil {
c.notify.RecordAccessHit(context.Background(), hitIP)
}
} }
func (c *Collector) ensureHost(host string) *hostState { func (c *Collector) ensureHost(host string) *hostState {
+302 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"version": "1.0.1", "version": "1.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fonu-web", "name": "fonu-web",
"version": "1.0.1", "version": "1.2.6",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
@@ -30,6 +30,7 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^5.0.1",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
}, },
@@ -662,12 +663,33 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@juggle/resize-observer": { "node_modules/@juggle/resize-observer": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
@@ -1124,6 +1146,24 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
@@ -1173,6 +1213,64 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/mocker": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.1",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.15", "version": "2.4.15",
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -1349,6 +1447,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@@ -1372,6 +1480,16 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/codemirror": { "node_modules/codemirror": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
@@ -1463,6 +1581,13 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
@@ -1517,6 +1642,16 @@
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -1663,6 +1798,20 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1769,6 +1918,13 @@
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sortablejs": { "node_modules/sortablejs": {
"version": "1.15.7", "version": "1.15.7",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz",
@@ -1784,12 +1940,46 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/style-mod": { "node_modules/style-mod": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1920,6 +2110,99 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.1",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.1",
"@vitest/browser-preview": "5.0.1",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.1",
"@vitest/coverage-v8": "5.0.1",
"@vitest/ui": "5.0.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/vooks": { "node_modules/vooks": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
@@ -2042,6 +2325,23 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
+4 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.2.6", "version": "1.2.8",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.1", "@codemirror/commands": "^6.11.1",
@@ -33,6 +34,7 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^5.0.1",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
} }
@@ -0,0 +1,44 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
/**
* 页面可见时才轮询:标签页隐藏期间跳过这一轮,回到前台立即补一次。
*
* 各页原来的写法是「自己 setInterval + 在回调里判断 document.visibilityState」,
* 但只有仪表盘这么做了,其余页面在后台标签页里仍在持续打接口(公网反代页每轮 7 个请求)。
*
* @param fn 每轮执行的回调;由调用方自行保证幂等与「无数据时直接 return」
* @param intervalMs 轮询间隔
* @param enabled 可选开关,为 false 时整轮跳过(例如未选中服务端)
*/
export function useVisibilityPolling(fn: () => void | Promise<void>, intervalMs: number, enabled?: Ref<boolean>) {
let timer: number | undefined
const tick = () => {
if (document.visibilityState === 'hidden') return
if (enabled && !enabled.value) return
void fn()
}
const stop = () => {
if (timer === undefined) return
window.clearInterval(timer)
timer = undefined
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') return
tick() // 回到前台立刻补一次,不用等下一个周期
}
onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange)
timer = window.setInterval(tick, intervalMs)
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
stop()
})
return { stop }
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { formatBytes, formatLogLine, formatLogTime, formatRate, formatRelativeTime, formatUptime } from './format'
describe('formatBytes', () => {
it('按 1024 进制换算并按量级保留有效位', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(1024)).toBe('1.00 KB')
expect(formatBytes(1536)).toBe('1.50 KB')
expect(formatBytes(20 * 1024)).toBe('20.0 KB')
expect(formatBytes(200 * 1024)).toBe('200 KB')
expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB')
})
it('非法输入回落 0 B,不产生 NaN', () => {
expect(formatBytes(Number.NaN)).toBe('0 B')
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B')
expect(formatBytes(-1)).toBe('0 B')
})
})
describe('formatRate', () => {
it('复用字节格式化并加 /s', () => {
expect(formatRate(0)).toBe('0 B/s')
expect(formatRate(2048)).toBe('2.00 KB/s')
})
})
describe('formatUptime', () => {
it('小时为 0 时只显示分钟', () => {
expect(formatUptime(90)).toBe('1 分钟')
expect(formatUptime(3661)).toBe('1 小时 1 分钟')
})
})
describe('formatRelativeTime', () => {
it('按分钟 / 小时 / 天分档', () => {
const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString()
expect(formatRelativeTime(undefined)).toBe('-')
expect(formatRelativeTime(ago(0))).toBe('刚刚')
expect(formatRelativeTime(ago(5))).toBe('5 分钟前')
expect(formatRelativeTime(ago(3 * 60))).toBe('3 小时前')
expect(formatRelativeTime(ago(2 * 24 * 60))).toBe('2 天前')
})
it('无法解析的字符串原样返回,不显示 NaN', () => {
expect(formatRelativeTime('not-a-date')).toBe('not-a-date')
})
})
describe('formatLogTime', () => {
it('三种日志时间格式统一成 YYYY-MM-DD HH:mm:ss', () => {
expect(formatLogTime('2026-09-24 18:30:05')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('2026-09-24T18:30:05Z')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('2026/09/24 18:30:05')).toBe('2026-09-24 18:30:05')
expect(formatLogTime('')).toBe('-')
})
})
describe('formatLogLine', () => {
it('只改写行首时间戳,保留其余内容', () => {
expect(formatLogLine('2026/09/24 18:30:05 [error] boom')).toBe('2026-09-24 18:30:05 [error] boom')
expect(formatLogLine('2026-09-24T18:30:05Z upstream timeout')).toBe('2026-09-24 18:30:05 upstream timeout')
})
it('JSON 日志只替换 time 字段', () => {
expect(formatLogLine('{"time":"2026-09-24T18:30:05Z","level":"info"}')).toBe('{"time":"2026-09-24 18:30:05","level":"info"}')
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { httpStatusKind, statusKind, statusLabel } from './status'
describe('statusKind', () => {
it('把后端状态值映射成 StatusKind', () => {
expect(statusKind('ok')).toBe('success')
expect(statusKind('running')).toBe('success')
expect(statusKind('warning')).toBe('warning')
expect(statusKind('error')).toBe('error')
expect(statusKind('stopped')).toBe('disabled')
expect(statusKind('none')).toBe('disabled')
expect(statusKind(undefined)).toBe('unknown')
expect(statusKind('')).toBe('unknown')
})
it('回归:n-tag 的 type 值不是状态值,必须落到 unknown', () => {
// 曾把 serverStartupType() 返回的 'success'/'default' 传给 StatusBadge :value,
// 导致服务端详情弹窗「运行中」也显示灰点。
expect(statusKind('success')).toBe('unknown')
expect(statusKind('default')).toBe('unknown')
})
})
describe('statusLabel', () => {
it('已知值给中文,未知值原样返回', () => {
expect(statusLabel('ok')).toBe('正常')
expect(statusLabel('stopped')).toBe('已停止')
expect(statusLabel('weird')).toBe('weird')
expect(statusLabel()).toBe('未知')
})
})
describe('httpStatusKind', () => {
it('按状态码分段', () => {
expect(httpStatusKind(200)).toBe('success')
expect(httpStatusKind(301)).toBe('unknown')
expect(httpStatusKind(404)).toBe('warning')
expect(httpStatusKind(503)).toBe('error')
})
})
-3
View File
@@ -1859,9 +1859,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); } .detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); }
.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); } .detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; } .detail-muted { color: var(--fonu-text-secondary); font-size: 12px; }
.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); }
.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; }
.diag-checks { display: inline-flex; flex-wrap: wrap; gap: 6px; } .diag-checks { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.server-detail__location { display: flex; align-items: center; gap: 5px; } .server-detail__location { display: flex; align-items: center; gap: 5px; }
.detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); } .detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
+7
View File
@@ -794,7 +794,12 @@ function recordRateSample() {
rateHistory.value = rateHistory.value.filter((p) => p.at >= cutoff) rateHistory.value = rateHistory.value.filter((p) => p.at >= cutoff)
} }
let trafficInFlight = false
async function refreshTraffic() { async function refreshTraffic() {
// 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮
if (trafficInFlight) return
trafficInFlight = true
try { try {
const rows = asList(await api.getProxyTraffic()) const rows = asList(await api.getProxyTraffic())
const next: Record<number, ProxyTraffic> = {} const next: Record<number, ProxyTraffic> = {}
@@ -805,6 +810,8 @@ async function refreshTraffic() {
recordRateSample() recordRateSample()
} catch { } catch {
// ignore polling errors // ignore polling errors
} finally {
trafficInFlight = false
} }
} }
+7 -80
View File
@@ -657,6 +657,7 @@ import { formatBytes, formatDate, formatRate } from '../utils/format'
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types' import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import LoadError from '../components/LoadError.vue' import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue' import StatusBadge from '../components/StatusBadge.vue'
@@ -698,7 +699,6 @@ const downloadProxyOptions = [
{ label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' }, { label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' },
] ]
const editingProxy = ref<FrpProxy | null>(null) const editingProxy = ref<FrpProxy | null>(null)
let timer: number | undefined
let downloadTimer: number | undefined let downloadTimer: number | undefined
function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } { function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } {
@@ -1204,8 +1204,11 @@ function proxyTransportTags(proxy: FrpProxy) {
return tags return tags
} }
onMounted(async () => { await load(); timer = window.setInterval(load, 5_000) }) // 轮询走 useVisibilityPolling:标签页隐藏时暂停,回到前台补一次
onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() }) useVisibilityPolling(() => load(), 5_000)
onMounted(async () => { await load() })
onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
</script> </script>
<style scoped> <style scoped>
@@ -1223,24 +1226,7 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.stat-card__value--sm { font-size: 18px; } .stat-card__value--sm { font-size: 18px; }
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); } .stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--fonu-text); }
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); } .stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--fonu-text-muted); }
.section-card { margin-bottom: var(--fonu-space-5); }
.section-actions, .switch-row, .modal-actions, .logs-toolbar { display: flex; align-items: center; gap: var(--fonu-space-2); flex-wrap: wrap; }
/* 公网服务端列表:n-data-table(与规则表一致);单元格由 render 创建,样式必须 :deep() */ /* 公网服务端列表:n-data-table(与规则表一致);单元格由 render 创建,样式必须 :deep() */
.frp-server-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
.frp-server-table :deep(.cell-name) { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.frp-server-table :deep(.cell-inline) { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
.frp-server-table :deep(.cell-inline .n-icon) { flex: 0 0 auto; color: var(--fonu-text-muted); font-size: 15px; }
.frp-server-table :deep(.cell-wrap) { overflow-wrap: anywhere; }
.frp-server-table :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; }
.frp-server-table :deep(.cell-actions) { display: flex; align-items: center; justify-content: flex-end; gap: var(--fonu-space-2); flex-wrap: wrap; }
.frp-server-table :deep(.n-data-table-td) { vertical-align: middle; }
.logs-modal { width: min(860px, calc(100vw - 32px)); }
.logs-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: var(--fonu-space-3); }
.logs-toolbar__filter { width: 200px; }
.rule-detail__fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-3) var(--fonu-space-5); }
.rule-detail__fields label { display: block; margin-bottom: 4px; color: var(--fonu-text-secondary); font-size: 12px; }
.rule-detail__fields strong { font-weight: 500; overflow-wrap: anywhere; }
.rule-detail__transport { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-4); }
.rule-detail__logs { max-height: 420px; } .rule-detail__logs { max-height: 420px; }
.rule-detail__log-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); } .rule-detail__log-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); }
/* 详情弹窗概览区:与反代详情同版式 */ /* 详情弹窗概览区:与反代详情同版式 */
@@ -1384,7 +1370,6 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--fonu-space-4); font-size: 16px; } .form-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 var(--fonu-space-4); font-size: 16px; }
.form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--fonu-radius-sm); background: var(--fonu-brand); color: #fff; font-size: 15px; } .form-section h3 :deep(.n-icon) { display: grid; place-items: center; width: 26px; height: 26px; border-radius: var(--fonu-radius-sm); background: var(--fonu-brand); color: #fff; font-size: 15px; }
.form-hint { margin: 0 0 var(--fonu-space-4); color: var(--fonu-text-secondary); font-size: 13px; line-height: 1.55; } .form-hint { margin: 0 0 var(--fonu-space-4); color: var(--fonu-text-secondary); font-size: 13px; line-height: 1.55; }
.narrow-input { max-width: 200px; }
/* 统一卡片式弹窗外壳(运行日志 / 导出配置 / frps 同源配置):与 .proxy-modal 体系一致的头部 + 滚动区 + 页脚 */ /* 统一卡片式弹窗外壳(运行日志 / 导出配置 / frps 同源配置):与 .proxy-modal 体系一致的头部 + 滚动区 + 页脚 */
.frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); } .frp-card-modal { display: flex; flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; } .frp-card-modal .proxy-modal__header { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); padding: var(--fonu-space-5) var(--fonu-space-5) 0; flex-shrink: 0; }
@@ -1393,9 +1378,6 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; } .frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--fonu-space-3); padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); border-top: 1px solid var(--fonu-border); margin-top: var(--fonu-space-2); flex-shrink: 0; }
.frp-runtime-modal { width: min(880px, 92vw); } .frp-runtime-modal { width: min(880px, 92vw); }
/* 服务端详情弹窗(仍用 naive-ui preset=card):排版对齐 .proxy-modal */ /* 服务端详情弹窗(仍用 naive-ui preset=card):排版对齐 .proxy-modal */
.frp-detail-card :deep(.n-card-header) { padding: var(--fonu-space-5) var(--fonu-space-5) 0; }
.frp-detail-card :deep(.n-card-header__main) { font-size: 18px; font-weight: 600; color: var(--fonu-text); }
.frp-detail-card :deep(.n-card__content) { padding: var(--fonu-space-4) var(--fonu-space-5) var(--fonu-space-5); }
.frp-guide-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); } .frp-guide-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--fonu-surface); border-radius: var(--fonu-radius); overflow: hidden; box-shadow: var(--fonu-shadow-md); }
.frp-guide-modal .proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; } .frp-guide-modal .proxy-modal__form { flex: 1; min-width: 0; display: flex; flex-direction: column; max-height: 90vh; }
@@ -1420,30 +1402,16 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.guide-topo { margin: 8px 0; padding: 10px 12px; border-radius: 6px; background: var(--fonu-bg-muted); font-size: 12px; line-height: 1.6; overflow-x: auto; } .guide-topo { margin: 8px 0; padding: 10px 12px; border-radius: 6px; background: var(--fonu-bg-muted); font-size: 12px; line-height: 1.6; overflow-x: auto; }
.guide-steps { margin: 8px 0; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; font-size: 13px; line-height: 1.7; } .guide-steps { margin: 8px 0; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; font-size: 13px; line-height: 1.7; }
.guide-collapse :deep(.n-collapse-item__header) { font-size: 13px; font-weight: 500; } .guide-collapse :deep(.n-collapse-item__header) { font-size: 13px; font-weight: 500; }
.guide-tabs { margin-top: -6px; }
.agent-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px 16px; }
.agent-grid > div { display: grid; gap: 4px; min-width: 0; }
.agent-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.agent-grid .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-sub { margin-top: 10px; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.agent-output { margin-top: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--fonu-space-4); } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--fonu-space-4); }
.form-grid--wide > :first-child { grid-column: 1 / -1; } .form-grid--wide > :first-child { grid-column: 1 / -1; }
.tls-panel { padding: var(--fonu-space-4); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg-muted); }
.tls-file-field { margin-bottom: var(--fonu-space-4); }
.tls-file-field__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); margin-bottom: var(--fonu-space-2); font-size: 14px; font-weight: 500; }
.file-input { display: none; }
.asset-actions { display: flex; gap: var(--fonu-space-2); margin-top: calc(-1 * var(--fonu-space-2)); }
.advanced-collapse { margin-top: var(--fonu-space-3); } .advanced-collapse { margin-top: var(--fonu-space-3); }
.switch-row, .modal-actions { display: flex; align-items: center; gap: var(--fonu-space-2); flex-wrap: wrap; }
.switch-row { padding: 4px 0 var(--fonu-space-5); } .switch-row { padding: 4px 0 var(--fonu-space-5); }
.switch-row span:nth-of-type(2) { margin-left: var(--fonu-space-4); } .switch-row span:nth-of-type(2) { margin-left: var(--fonu-space-4); }
.modal-actions { justify-content: flex-end; } .modal-actions { justify-content: flex-end; }
.logs-box { margin: var(--fonu-space-3) 0 0; padding: var(--fonu-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--fonu-border); background: var(--fonu-bg-muted); color: var(--fonu-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); } .logs-box { margin: var(--fonu-space-3) 0 0; padding: var(--fonu-space-4); min-height: 260px; max-height: 480px; overflow: auto; border: 1px solid var(--fonu-border); background: var(--fonu-bg-muted); color: var(--fonu-text); white-space: pre-wrap; word-break: break-word; font: 12px/1.55 var(--fonu-mono); }
.error-card__content { color: var(--fonu-error); white-space: pre-wrap; } .error-card__content { color: var(--fonu-error); white-space: pre-wrap; }
.table-muted { margin-top: 4px; color: var(--fonu-text-muted); font-size: 12px; } .table-muted { margin-top: 4px; color: var(--fonu-text-muted); font-size: 12px; }
.type-chip { font-family: var(--fonu-mono); font-size: 12px; color: var(--fonu-brand-text); }
/* 规则表单元格由 render 函数的 h() 创建,不带 scoped 属性,必须 :deep() 才能命中 */ /* 规则表单元格由 render 函数的 h() 创建,不带 scoped 属性,必须 :deep() 才能命中 */
.runtime-actions, .runtime-section__title, .binary-row, .binary-row__actions { display: flex; align-items: center; gap: var(--fonu-space-3); } .runtime-actions, .runtime-section__title, .binary-row, .binary-row__actions { display: flex; align-items: center; gap: var(--fonu-space-3); }
.runtime-actions { justify-content: flex-end; margin-bottom: var(--fonu-space-4); } .runtime-actions { justify-content: flex-end; margin-bottom: var(--fonu-space-4); }
@@ -1465,46 +1433,6 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.release-progress__bar { min-width: 0; } .release-progress__bar { min-width: 0; }
.release-progress__value { font-variant-numeric: tabular-nums; } .release-progress__value { font-variant-numeric: tabular-nums; }
.release-progress--failed { color: var(--fonu-error); grid-template-columns: minmax(0, 1fr); } .release-progress--failed { color: var(--fonu-error); grid-template-columns: minmax(0, 1fr); }
.server-detail { display: grid; gap: var(--fonu-space-4); }
.server-detail__top, .server-detail__section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
.server-detail__top h2 { margin: 0; font-size: 24px; }
.server-detail__top p { margin: 4px 0 0; color: var(--fonu-text-secondary); font-size: 13px; }
.server-detail__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.server-detail__panel { padding: var(--fonu-space-4); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.server-detail__panel h3 { margin: 0 0 var(--fonu-space-4); font-size: 16px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.detail-fields--single { grid-template-columns: 1fr; }
.detail-fields > div { display: grid; gap: 5px; min-width: 0; }
.detail-fields label, .detail-stats label { color: var(--fonu-text-secondary); font-size: 12px; }
.detail-fields strong, .detail-fields span { overflow-wrap: anywhere; }
.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); }
.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; }
.detail-subgroup { margin-top: var(--fonu-space-3); padding-top: var(--fonu-space-3); border-top: 1px solid var(--fonu-border); }
.detail-subgroup__title { margin-bottom: var(--fonu-space-2); font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
.detail-muted { color: var(--fonu-text-secondary); font-size: 12px; }
.diag-checks { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.server-detail__location { display: flex; align-items: center; gap: 5px; }
.detail-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.detail-stats > div { display: grid; gap: 5px; }
.detail-stats strong { font-size: 20px; color: var(--fonu-success); }
.server-status-summary { display: grid; grid-template-columns: minmax(150px, 1.3fr) repeat(3, minmax(110px, 1fr)); align-items: center; gap: var(--fonu-space-4); }
.server-status-summary__item { display: grid; gap: 6px; min-width: 0; }
.server-status-summary__item label { color: var(--fonu-text-secondary); font-size: 12px; }
.server-status-summary__item strong { color: var(--fonu-success); font-size: 18px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.server-status-summary__item--state { display: flex; align-items: center; min-height: 28px; padding-right: var(--fonu-space-4); border-right: 1px solid var(--fonu-border); }
.status-bars { display: grid; grid-template-columns: repeat(30, minmax(0, 1fr)); gap: 4px; margin-top: var(--fonu-space-5); min-height: 36px; }
.status-bar { position: relative; min-width: 0; height: 36px; border-radius: 4px; background: var(--fonu-bg-muted); cursor: help; outline: none; transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease; }
.status-bar--ok { background: linear-gradient(180deg, color-mix(in srgb, var(--fonu-success) 75%, white), color-mix(in srgb, var(--fonu-success) 48%, white)); }
.status-bar--failed { background: color-mix(in srgb, var(--fonu-error) 68%, white); }
.status-bar:hover, .status-bar:focus-visible { z-index: 2; transform: translateY(-2px); filter: brightness(1.1); box-shadow: var(--fonu-shadow-md); }
.status-bar::after { content: attr(data-tooltip); position: absolute; left: 50%; bottom: calc(100% + 9px); z-index: 3; max-width: 240px; padding: 6px 8px; border-radius: 6px; background: var(--fonu-text); color: var(--fonu-surface); font-size: 12px; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.status-bar::before { content: ''; position: absolute; left: 50%; bottom: calc(100% + 4px); z-index: 3; border: 5px solid transparent; border-top-color: var(--fonu-text); pointer-events: none; opacity: 0; transform: translate(-50%, 4px); transition: opacity 160ms ease, transform 160ms ease; }
.status-bar:hover::after, .status-bar:hover::before, .status-bar:focus-visible::after, .status-bar:focus-visible::before { opacity: 1; transform: translate(-50%, 0); }
.status-bars-empty { display: grid; place-items: center; min-height: 36px; margin-top: var(--fonu-space-5); border: 1px dashed var(--fonu-border); color: var(--fonu-text-muted); font-size: 12px; }
.status-bars-footer { display: flex; justify-content: space-between; margin-top: var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; }
.status-bars-footer span:last-child { display: inline-flex; align-items: center; gap: 5px; }
.status-bars-footer i { width: 6px; height: 6px; border-radius: 50%; background: color-mix(in srgb, var(--fonu-success) 35%, white); }
.latency-panel__hint { color: var(--fonu-text-muted); font-size: 12px; } .latency-panel__hint { color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-meta { display: flex; justify-content: space-between; gap: var(--fonu-space-3); margin: -4px 0 var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; } .detail-status-meta { display: flex; justify-content: space-between; gap: var(--fonu-space-3); margin: -4px 0 var(--fonu-space-3); color: var(--fonu-text-muted); font-size: 12px; }
.detail-status-error { color: var(--fonu-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-status-error { color: var(--fonu-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1550,7 +1478,6 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
.rule-pane__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-2); } .rule-pane__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--fonu-space-2); }
.rule-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); box-shadow: var(--fonu-shadow); overflow: hidden; padding: var(--fonu-space-2) 0; } .rule-table-wrap { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); background: var(--fonu-surface); box-shadow: var(--fonu-shadow); overflow: hidden; padding: var(--fonu-space-2) 0; }
.frp-rules-table :deep(.table-muted) { color: var(--fonu-text-muted); font-size: 12px; } .frp-rules-table :deep(.table-muted) { color: var(--fonu-text-muted); font-size: 12px; }
.frp-rules-table :deep(.type-chip) { font-family: var(--fonu-mono); font-size: 12px; color: var(--fonu-brand-text); }
.frp-rules-table :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; } .frp-rules-table :deep(.mono) { font-family: var(--fonu-mono); font-size: 13px; }
.frp-rules-table :deep(.text-secondary) { color: var(--fonu-text-secondary); } .frp-rules-table :deep(.text-secondary) { color: var(--fonu-text-secondary); }
@media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } } @media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
+19 -19
View File
@@ -365,12 +365,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, h, onMounted, ref, watch } from 'vue'
import { NButton, NIcon, NPopconfirm, useMessage, type DataTableColumns } from 'naive-ui' import { NButton, NIcon, NPopconfirm, useMessage, type DataTableColumns } from 'naive-ui'
import { import {
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline, CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue' import EmptyState from '../components/EmptyState.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import FonuCard from '../components/FonuCard.vue' import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue' import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue' import PageHeader from '../components/PageHeader.vue'
@@ -429,7 +430,6 @@ const settingsForm = ref({
const confModal = ref(false) const confModal = ref(false)
const confDomain = ref('') const confDomain = ref('')
const confContent = ref('') const confContent = ref('')
let timer: number | undefined
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null) const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null) const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
@@ -486,6 +486,7 @@ async function load() {
} }
// refreshAll:单个接口失败不再中断整轮,失败项汇总成一句可见提示(原先 loadErrorMsg 从未渲染) // refreshAll:单个接口失败不再中断整轮,失败项汇总成一句可见提示(原先 loadErrorMsg 从未渲染)
// 7 个接口彼此独立,并行发:此前串行 await 让每次刷新等于 7 个往返相加
async function refreshAll(options: { silent?: boolean } = { silent: true }) { async function refreshAll(options: { silent?: boolean } = { silent: true }) {
const current = server.value const current = server.value
if (!current) return if (!current) return
@@ -496,19 +497,21 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
failures.push(`${label}${error instanceof Error ? `:${error.message}` : '失败'}`) failures.push(`${label}${error instanceof Error ? `:${error.message}` : '失败'}`)
} }
} }
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value }) await Promise.all([
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value }) track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value }),
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value }) track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value }),
await track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value }) track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value }),
await track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value }) track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value }),
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => { track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value }),
track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
proxyTraffic.value = value.items ?? {} proxyTraffic.value = value.items ?? {}
trafficErrors.value = value.errors ?? {} trafficErrors.value = value.errors ?? {}
}) }),
await track('Agent 状态', () => api.getFrpAgentStatus(current.id), (value) => { track('Agent 状态', () => api.getFrpAgentStatus(current.id), (value) => {
agentStatus.value = value agentStatus.value = value
statusLoaded.value = true statusLoaded.value = true
}) }),
])
loadErrorMsg.value = failures.length ? `数据读取不完整 —— ${failures.join(';')}` : '' loadErrorMsg.value = failures.length ? `数据读取不完整 —— ${failures.join(';')}` : ''
if (options.silent === false) manualRefreshing.value = false if (options.silent === false) manualRefreshing.value = false
} }
@@ -865,17 +868,14 @@ watch(selectedId, () => {
void refreshAll({ silent: false }) void refreshAll({ silent: false })
}) })
onMounted(() => { // 轮询走 useVisibilityPolling:标签页隐藏时暂停,回到前台补一次
void load() useVisibilityPolling(() => {
timer = window.setInterval(() => { if (!server.value) return // 未选服务端时不轮询
if (!server.value) return
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
if (busy.value) return // 有操作进行中不叠加轮询 if (busy.value) return // 有操作进行中不叠加轮询
void refreshAll() void refreshAll()
}, 15000) }, 15000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) }) onMounted(() => { void load() })
</script> </script>
<style scoped> <style scoped>