fix(web,agent): P1 正确性与鉴权加固,P2 抽 fsutil 并补测试(v1.2.8)
This commit is contained in:
+1
-4
@@ -6,11 +6,8 @@ bin
|
||||
/data
|
||||
# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文)
|
||||
.data
|
||||
# 已弃用归档代码,不参与编译
|
||||
internal/frp-1.0.2
|
||||
web/src/views/FrpView-1.0.2.vue
|
||||
# 文档与本地杂项
|
||||
docs
|
||||
docs
|
||||
# 本地导出/打包产物与日志(预防性:不进入构建上下文)
|
||||
*.log
|
||||
*.rar
|
||||
|
||||
@@ -33,6 +33,10 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Run web unit tests
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: Sync web dist into embed directory
|
||||
run: |
|
||||
rm -rf cmd/fonu/web/dist
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -153,7 +153,7 @@ docker compose up -d --build
|
||||
| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 |
|
||||
| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 |
|
||||
| `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 文件路径 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@
|
||||
| Docker | 20.10+(含 compose v2) |
|
||||
| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) |
|
||||
| 磁盘 | ≥ 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 模式) |
|
||||
|
||||
## 2. 构建镜像(所有部署方式的前提)
|
||||
@@ -50,7 +50,7 @@
|
||||
docker compose build
|
||||
```
|
||||
|
||||
一次构建同时产出:前端 `dist`、Go 后端二进制、内置 frpc 0.71.0 的运行镜像,标签为 `fonu:local`。
|
||||
一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `fonu:local`。
|
||||
|
||||
**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。
|
||||
|
||||
|
||||
@@ -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 引用。
|
||||
@@ -1,5 +1,7 @@
|
||||
# fonu-agent 能力扩展方案
|
||||
|
||||
> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。
|
||||
|
||||
> 本文盘点 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`(均为读代码确认,非推测)。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 公网 Agent 页(`AgentView.vue`)设计方案 v2
|
||||
|
||||
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
|
||||
|
||||
> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**:
|
||||
> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 公网 Agent 页(`AgentView.vue`)设计方案
|
||||
|
||||
> **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。
|
||||
|
||||
> 本文按 `breezell-design` 两遍流程产出:**Pass 1a 检测栈 → Pass 1b 确定设计方向与令牌 → Pass 2 实施规格**,
|
||||
> 并结合本项目(Fonu)既有页面与设计系统。落地时以本文为准,`docs/agent-view-refactor-plan.md`(结构整改)被本文吸收。
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 上游同步方案: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)。
|
||||
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
|
||||
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
|
||||
|
||||
@@ -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
@@ -25,6 +25,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,8 @@ import (
|
||||
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
|
||||
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
|
||||
// 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 配置。
|
||||
type Config struct {
|
||||
@@ -181,7 +183,7 @@ func (s *Server) Handler() http.Handler {
|
||||
}
|
||||
|
||||
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() {
|
||||
<-ctx.Done()
|
||||
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()
|
||||
}
|
||||
// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
|
||||
if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil {
|
||||
if exec.Command("pgrep", "-x", "frps").Run() == nil {
|
||||
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
if !active && exec.Command("pgrep", "-x", "frps").Run() == nil {
|
||||
_ = exec.Command("pkill", "-x", "frps").Run()
|
||||
time.Sleep(time.Second)
|
||||
notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
|
||||
}
|
||||
_ = exec.Command("systemctl", "enable", "--now", "frps").Run()
|
||||
notes = append(notes, "frps.service 已启动")
|
||||
} else {
|
||||
_ = exec.Command("systemctl", "restart", "frps").Run()
|
||||
notes = append(notes, "frps.service 已重启以加载新版本")
|
||||
args := []string{"restart", "frps"}
|
||||
label := "已重启以加载新版本"
|
||||
if !active {
|
||||
args = []string{"enable", "--now", "frps"}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -1511,24 +1522,7 @@ func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*")
|
||||
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)
|
||||
return fsutil.WriteFileAtomic(path, data, mode)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
@@ -15,10 +16,67 @@ import (
|
||||
type AuthHandler struct {
|
||||
auth *auth.Service
|
||||
notify *notify.Service
|
||||
guard *loginGuard
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -55,11 +113,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnauthorized) {
|
||||
h.guard.fail(ip)
|
||||
if h.notify != nil {
|
||||
h.notify.RecordLoginFailure(r.Context(), clientIP(r))
|
||||
h.notify.RecordLoginFailure(r.Context(), ip)
|
||||
}
|
||||
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
@@ -67,7 +131,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusInternalServerError, "登录失败")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionID)
|
||||
h.guard.reset(ip)
|
||||
setSessionCookie(w, r, sessionID)
|
||||
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, "未登录或会话已过期")
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -97,21 +162,31 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
||||
_ = h.auth.Logout(r.Context(), sessionID)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
clearSessionCookie(w, r)
|
||||
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{
|
||||
Name: "fonu_session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
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 {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -120,12 +195,14 @@ func clientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
CA string `json:"ca"`
|
||||
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
|
||||
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
||||
domains = []string{req.Domain}
|
||||
@@ -141,7 +144,10 @@ func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
|
||||
@@ -119,7 +119,10 @@ func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
APITokenID string `json:"api_token_id"`
|
||||
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{
|
||||
ConfigID: req.ConfigID,
|
||||
Provider: req.Provider,
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -606,7 +608,11 @@ func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Requ
|
||||
Version string `json:"version"`
|
||||
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 == "" {
|
||||
body.Version = "0.71.0"
|
||||
}
|
||||
|
||||
@@ -123,9 +123,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
frpMultiSvc.SetBaseContext(ctx)
|
||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 5
|
||||
|
||||
@@ -118,7 +118,7 @@ func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int,
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -134,7 +134,11 @@ func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword,
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
@@ -96,7 +97,7 @@ func (s *Service) Update(ctx context.Context) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -42,7 +42,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
|
||||
var b strings.Builder
|
||||
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")
|
||||
// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
|
||||
b.WriteString("bindAddr = \"0.0.0.0\"\n")
|
||||
|
||||
+59
-11
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
)
|
||||
|
||||
@@ -39,9 +40,15 @@ type Service struct {
|
||||
|
||||
downloadMu sync.RWMutex
|
||||
downloadTask DownloadTask
|
||||
// baseCtx 是应用级 ctx:后台下载随之取消(之前用 context.Background(),App 关闭后仍在跑)
|
||||
baseCtx context.Context
|
||||
locationMu sync.Mutex
|
||||
locations map[string]locationCache
|
||||
|
||||
// agentClients 缓存每台服务端的 agent HTTP 客户端(含连接池),按 URL+Token 指纹失效
|
||||
agentMu sync.Mutex
|
||||
agentClients map[int64]cachedAgentClient
|
||||
|
||||
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
|
||||
diagMu sync.Mutex
|
||||
diagCache map[int64]diagnosticsCacheEntry
|
||||
@@ -167,6 +174,13 @@ func (s *Service) StartDownload(version string) (DownloadTask, error) {
|
||||
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 {
|
||||
s.downloadMu.RLock()
|
||||
defer s.downloadMu.RUnlock()
|
||||
@@ -174,7 +188,13 @@ func (s *Service) DownloadStatus() DownloadTask {
|
||||
}
|
||||
|
||||
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()
|
||||
binary, err := s.downloadRelease(ctx, version, s.updateDownload)
|
||||
if err != nil {
|
||||
@@ -772,10 +792,20 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置
|
||||
if server, err := s.store.GetServer(ctx, serverID); err == nil {
|
||||
if content, err := s.buildFRPSServerConfig(ctx, server); err == nil {
|
||||
_ = client.PushFRPSConfig(ctx, content)
|
||||
// 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置。
|
||||
// 失败不能吞掉:否则前端只看到「安装成功」,VPS 上跑的仍是旧配置。
|
||||
warning := ""
|
||||
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
|
||||
@@ -1259,6 +1289,12 @@ func (s *Service) DeleteProxy(ctx context.Context, id int64) error {
|
||||
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) {
|
||||
server, err := s.store.GetServer(ctx, serverID)
|
||||
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 == "" {
|
||||
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) {
|
||||
@@ -1494,8 +1542,10 @@ func (s *Service) persistServerConfigForVerify(ctx context.Context, server Serve
|
||||
_ = os.Remove(officialPath + ".verify")
|
||||
return err
|
||||
}
|
||||
if current, readErr := os.ReadFile(officialPath); readErr == nil {
|
||||
_ = os.WriteFile(officialPath+".bak", current, 0o600)
|
||||
// 备份失败就不能覆盖唯一副本:否则旧配置没有回退、只剩 .verify 里的新内容
|
||||
if err := fsutil.Backup(officialPath); err != nil {
|
||||
_ = os.Remove(officialPath + ".verify")
|
||||
return fmt.Errorf("备份现有 frps 配置失败,已保留原配置: %w", err)
|
||||
}
|
||||
return os.Rename(officialPath+".verify", officialPath)
|
||||
}
|
||||
@@ -1820,11 +1870,9 @@ func writeConfigAtomically(path, content string) error {
|
||||
}
|
||||
// 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename),
|
||||
// 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。
|
||||
if current, err := os.ReadFile(path); err == nil {
|
||||
if err := os.WriteFile(path+".bak", current, 0o600); err != nil {
|
||||
if err := fsutil.Backup(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "frpc-*.toml")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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 魔数应被识别")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
)
|
||||
|
||||
const maxCustomBackups = 10
|
||||
@@ -141,21 +142,21 @@ func RestoreBackup(backupDir, backupName, targetPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(targetPath, data, 0o644)
|
||||
return fsutil.WriteFileAtomic(targetPath, data, 0o644)
|
||||
}
|
||||
|
||||
func WriteGlobalCustom(cfg config.Config, content string) error {
|
||||
if err := EnsureCustomDirs(cfg); err != nil {
|
||||
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 {
|
||||
if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil {
|
||||
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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"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"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
if err := fsutil.WriteFileAtomic(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
@@ -130,13 +131,11 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
|
||||
}
|
||||
|
||||
currentPath := m.cfg.NginxConfigPath()
|
||||
backupPath := currentPath + ".bak"
|
||||
if _, err := os.Stat(currentPath); err == nil {
|
||||
if err := copyFile(currentPath, backupPath); err != nil {
|
||||
// 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
|
||||
if err := fsutil.Backup(currentPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, currentPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
@@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,8 +16,17 @@ type Scheduler struct {
|
||||
jobs []Job
|
||||
}
|
||||
|
||||
// New 会丢掉间隔非正的任务:time.NewTicker 对非正间隔直接 panic,这类任务应由调用方单独启动。
|
||||
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) {
|
||||
@@ -28,13 +38,22 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
func (s *Scheduler) runJob(ctx context.Context, job Job) {
|
||||
ticker := time.NewTicker(job.Interval)
|
||||
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)
|
||||
}
|
||||
run()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
job.Run(ctx)
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,7 @@ func (c *Collector) ingestLine(line string, trackLive bool) {
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hitIP := ""
|
||||
c.mu.Lock()
|
||||
st := c.ensureHost(host)
|
||||
st.uploadTotal += up
|
||||
@@ -189,13 +190,15 @@ func (c *Collector) ingestLine(line string, trackLive bool) {
|
||||
}
|
||||
if entry.ClientIP != "" && entry.ClientIP != "-" {
|
||||
st.clients[entry.ClientIP] = now
|
||||
if c.notify != nil {
|
||||
c.notify.RecordAccessHit(context.Background(), entry.ClientIP)
|
||||
}
|
||||
// 锁内只记 IP:RecordAccessHit 会写库并判断通知,放在锁外,避免阻塞读侧
|
||||
hitIP = entry.ClientIP
|
||||
}
|
||||
c.pruneHost(st, now)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if hitIP != "" && c.notify != nil {
|
||||
c.notify.RecordAccessHit(context.Background(), hitIP)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) ensureHost(host string) *hostState {
|
||||
|
||||
Generated
+302
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fonu-web",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^5.0.1",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
},
|
||||
@@ -662,12 +663,33 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"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": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||
@@ -1124,6 +1146,24 @@
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1173,6 +1213,64 @@
|
||||
"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": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
|
||||
@@ -1349,6 +1447,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1372,6 +1480,16 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
||||
@@ -1463,6 +1581,13 @@
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -1517,6 +1642,16 @@
|
||||
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"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_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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -1769,6 +1918,13 @@
|
||||
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
|
||||
"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": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
@@ -1784,12 +1940,46 @@
|
||||
"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": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"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": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
|
||||
@@ -2042,6 +2325,23 @@
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
|
||||
|
||||
+4
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"private": true,
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^5.0.1",
|
||||
"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 }
|
||||
}
|
||||
@@ -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"}')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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__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); }
|
||||
|
||||
@@ -794,7 +794,12 @@ function recordRateSample() {
|
||||
rateHistory.value = rateHistory.value.filter((p) => p.at >= cutoff)
|
||||
}
|
||||
|
||||
let trafficInFlight = false
|
||||
|
||||
async function refreshTraffic() {
|
||||
// 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮
|
||||
if (trafficInFlight) return
|
||||
trafficInFlight = true
|
||||
try {
|
||||
const rows = asList(await api.getProxyTraffic())
|
||||
const next: Record<number, ProxyTraffic> = {}
|
||||
@@ -805,6 +810,8 @@ async function refreshTraffic() {
|
||||
recordRateSample()
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
} finally {
|
||||
trafficInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 FonuCard from '../components/FonuCard.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
@@ -698,7 +699,6 @@ const downloadProxyOptions = [
|
||||
{ label: 'cdn.gh-proxy.org', value: 'https://cdn.gh-proxy.org/' },
|
||||
]
|
||||
const editingProxy = ref<FrpProxy | null>(null)
|
||||
let timer: number | undefined
|
||||
let downloadTimer: number | undefined
|
||||
|
||||
function defaultProxyOptions(): FrpProxyOptions & { allow_ips_text?: string } {
|
||||
@@ -1204,8 +1204,11 @@ function proxyTransportTags(proxy: FrpProxy) {
|
||||
return tags
|
||||
}
|
||||
|
||||
onMounted(async () => { await load(); timer = window.setInterval(load, 5_000) })
|
||||
onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
|
||||
// 轮询走 useVisibilityPolling:标签页隐藏时暂停,回到前台补一次
|
||||
useVisibilityPolling(() => load(), 5_000)
|
||||
|
||||
onMounted(async () => { await load() })
|
||||
onUnmounted(() => { if (proxyRateTimer) { window.clearInterval(proxyRateTimer); proxyRateTimer = null } stopDownloadPolling() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1223,24 +1226,7 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
|
||||
.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__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() */
|
||||
.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__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 :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; }
|
||||
.narrow-input { max-width: 200px; }
|
||||
/* 统一卡片式弹窗外壳(运行日志 / 导出配置 / 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 .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-runtime-modal { width: min(880px, 92vw); }
|
||||
/* 服务端详情弹窗(仍用 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 .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-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-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--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); }
|
||||
.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 span:nth-of-type(2) { margin-left: var(--fonu-space-4); }
|
||||
.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); }
|
||||
.error-card__content { color: var(--fonu-error); white-space: pre-wrap; }
|
||||
.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() 才能命中 */
|
||||
.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); }
|
||||
@@ -1465,46 +1433,6 @@ onUnmounted(() => { if (timer) window.clearInterval(timer); if (proxyRateTimer)
|
||||
.release-progress__bar { min-width: 0; }
|
||||
.release-progress__value { font-variant-numeric: tabular-nums; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -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-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(.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(.text-secondary) { color: var(--fonu-text-secondary); }
|
||||
@media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
|
||||
|
||||
@@ -365,12 +365,13 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -429,7 +430,6 @@ const settingsForm = ref({
|
||||
const confModal = ref(false)
|
||||
const confDomain = ref('')
|
||||
const confContent = ref('')
|
||||
let timer: number | undefined
|
||||
|
||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
|
||||
@@ -486,6 +486,7 @@ async function load() {
|
||||
}
|
||||
|
||||
// refreshAll:单个接口失败不再中断整轮,失败项汇总成一句可见提示(原先 loadErrorMsg 从未渲染)
|
||||
// 7 个接口彼此独立,并行发:此前串行 await 让每次刷新等于 7 个往返相加
|
||||
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
@@ -496,19 +497,21 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
failures.push(`${label}${error instanceof Error ? `:${error.message}` : '失败'}`)
|
||||
}
|
||||
}
|
||||
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value })
|
||||
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value })
|
||||
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value })
|
||||
await track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value })
|
||||
await track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value })
|
||||
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||
await Promise.all([
|
||||
track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value }),
|
||||
track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value }),
|
||||
track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value }),
|
||||
track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value }),
|
||||
track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value }),
|
||||
track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||
proxyTraffic.value = value.items ?? {}
|
||||
trafficErrors.value = value.errors ?? {}
|
||||
})
|
||||
await track('Agent 状态', () => api.getFrpAgentStatus(current.id), (value) => {
|
||||
}),
|
||||
track('Agent 状态', () => api.getFrpAgentStatus(current.id), (value) => {
|
||||
agentStatus.value = value
|
||||
statusLoaded.value = true
|
||||
})
|
||||
}),
|
||||
])
|
||||
loadErrorMsg.value = failures.length ? `数据读取不完整 —— ${failures.join(';')}` : ''
|
||||
if (options.silent === false) manualRefreshing.value = false
|
||||
}
|
||||
@@ -865,17 +868,14 @@ watch(selectedId, () => {
|
||||
void refreshAll({ silent: false })
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = window.setInterval(() => {
|
||||
if (!server.value) return
|
||||
if (document.visibilityState === 'hidden') return // 页面不可见时暂停轮询
|
||||
// 轮询走 useVisibilityPolling:标签页隐藏时暂停,回到前台补一次
|
||||
useVisibilityPolling(() => {
|
||||
if (!server.value) return // 未选服务端时不轮询
|
||||
if (busy.value) return // 有操作进行中不叠加轮询
|
||||
void refreshAll()
|
||||
}, 15000)
|
||||
})
|
||||
}, 15000)
|
||||
|
||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user