8 Commits
v0.1.0 ... main

Author SHA1 Message Date
64d3359981 fix(web): 窄屏顶栏右侧溢出 — flex 自动最小尺寸 + vw 限宽两处误算
两个原因叠加导致顶栏横向溢出:

1. "webui4frpc" 是不可断行单词,.sb-brand-text 没给 min-width:0 +
   overflow:hidden,其 flex 自动最小尺寸就等于 min-content(≈85px),
   顶栏因此拿不出空间给右侧身份区,超出部分溢出。现给品牌文字整条链路
   加可收缩约束,标题用 ellipsis 截断。

2. .sb-identity 用 max-width:42vw / .id-name 用 14vw 限宽:vw 含垂直
   滚动条宽度,比实际可用内容宽度大;且 max-width 只封顶盒子,内部
   flex:0 0 auto 的角色徽标与退出按钮仍会把它撑开。改为全链路
   min-width:0 + overflow:hidden,由 flex 自行分配(.sb-foot 也从
   flex:0 0 auto 改 0 1 auto,允许被压缩)。

另加 .sidebar { overflow-x: hidden } 作兜底,任何子项算错都不再产生
横向滚动条;≤380px 隐藏角色徽标(它不可收缩,"超级管理员"≈70px)。

验证:tsc --noEmit 通过、npm run build 通过、产物 CSS 已确认规则生效;
三节点部署 0.1.2 后环正常(cycle 推进、pending=0、topo=5)。
2026-09-03 08:50:23 +08:00
0559cc55bd fix(web): 窄屏底部导航栏错位到顶部 — 顶栏 backdrop-filter 创建了包含块
.sb-nav 用 position:fixed;bottom:0 想贴视口底部,但父元素 .sidebar 带
backdrop-filter。backdrop-filter 与 transform/filter 同理,会为后代的
position:fixed 创建【包含块】—— 于是 bottom:0 变成相对顶栏下沿定位而不是
视口,tab bar 贴在顶栏正下方,看起来就是固定在屏幕顶部。

窄屏下关掉顶栏的 backdrop-filter,改用近实色底
color-mix(var(--w4f-card-solid) 92%, transparent);玻璃模糊保留在
.sb-nav 自身(元素自己的 backdrop-filter 不影响自己的定位)。

顺带 bump 到 0.1.2(version 包 / release.sh / build_installers.sh /
rpm spec + changelog / README 安装示例文件名)。

验证:tsc --noEmit 通过、npm run build 通过;产物 CSS 中确认 720px 段的
.sidebar 已含 backdrop-filter:none;三节点部署后 /status 报告 0.1.2,
环正常(cycle 推进、pending=0、5 条转发 frpc 进程与 topology 归属一致)。
2026-09-03 08:45:22 +08:00
609f0df517 chore: upload_assets.py 默认包含原生安装包
默认文件筛选原来只匹配 tar.gz/zip/SHA256SUMS,deb/rpm/pkg/setup.exe
必须逐个显式传参才会上传(v0.1.0 与 v0.1.1 都是分两趟传的)。
改为按发布产物后缀白名单筛选;用 -setup.exe 而非裸 .exe,避免误收
bin/ 下的裸二进制。
2026-09-03 07:30:53 +08:00
f9f18ae13c chore: v0.1.1 — 版本号收拢进 internal/version + 发布脚本校验和覆盖安装包
- 新增 internal/version 包作为版本号唯一来源,release.sh 通过
  -ldflags -X 注入真实 tag。此前 /api/manager/status 与集群 JoinInfo
  里各自硬编码 "0.1.0",产物报告的版本与 tag 无关。
- release.sh:SHA256SUMS 移到原生安装包构建之后统一重算,覆盖
  deb/rpm/setup.exe/pkg。此前只算 tar.gz/zip,用户校验安装包会得到
  "no file was verified"(v0.1.0 发布时实际踩到)。
- rpm spec / build_installers.sh 默认版本与 changelog、README 安装
  示例文件名同步到 0.1.1。

验证:go test ./internal/cluster/... ./internal/httpapi/... 通过;
6 平台产物架构逐个 file 校验正确;linux-amd64 实跑 /status 报告
version=0.1.1(确认 ldflags 注入生效)。
2026-09-03 07:28:13 +08:00
f935cad33f feat(web): 窄屏适配 — 侧栏转底部 tab bar + 各页响应式重排
此前只有一个 max-width:900px 断点(仅缩窄侧栏),手机上 210px 侧栏吃掉
半屏、卡片网格横向溢出、按钮被挤成细条。新增 720px / 380px 两档断点:

App.vue(关键)
- ≤720px:shell 转竖向,导航从侧栏抽出固定到屏幕底部(图标上/文字下),
  品牌+身份+主题折进顶部窄条;tab 最小 44px 触控高度
- 内容区预留 env(safe-area-inset-bottom),避开 iOS 手势条
- ≤380px 再压一档:隐藏权限徽标、缩小字号

StatusView
- card-grid 的 minmax(320px) 在 375px 屏溢出 → 单列
- KPI 由 auto-fit 改固定两列(auto-fit 窄屏退化单列使四卡拉长),≤380px 单列
- 转发卡 fwd-head 原一行塞 7~8 元素 → 路由占整行、按钮另起一行平分
- 分组标题按钮不再被 margin-left:auto 压扁

ClusterView
- 环拓扑改竖向,节点卡占满宽,箭头旋转 90° 指下
- nodeKey(32 位 hex)允许 break-all,不再撑宽容器
- 日志 lg-detail 由 max-width:60%+nowrap 改整行完整显示

SettingsView / UsersView / CanvasView
- binary-row、setting-row 竖排;表格卡标题行竖排、操作按钮平分
- 画布 toolbar 按钮换行平铺;vue-flow handle 12px→16px(触控可达)

index.html
- viewport 加 viewport-fit=cover,这是 env(safe-area-inset-*) 生效前提

验证:tsc --noEmit 通过、npm run build 通过、dist 已重新 embed,
三节点部署后环正常(pending=0,5 条转发 frpc 进程与 topology 归属一致)。
2026-09-03 07:20:32 +08:00
f9c2e9476a fix: 心跳复活节点、inflight 超时重发
1. handleClusterToken:心跳到达时把发送方(前驱)标记为 alive。
   之前 forwardToNext 超时把节点 MarkOffline 后就再无途径恢复,
   即使下一个心跳证明它活着。心跳是 leader 侧纠正误判的唯一入口。

2. forwardToNext:遍历完所有后继才返回,不要提前 inflight.clear()。
   保留 inflight 状态,让 WatchTokenLoss 超时后 StartRing 重发,
   重发时 AliveSuccessor 会重新计算,心跳刚复活的节点就能被选中。

这两处补丁一起提交,形成完整的存活复活闭环。
2026-08-28 16:32:51 +08:00
15b1dcce8f feat: 原生安装包 — deb/rpm/systemd + NSIS setup.exe + macOS pkg
此前交付的 tar.gz/zip 仅是解压直用包,补齐真正的平台安装包:
- Linux: .deb (dpkg-deb) 与 .rpm (rpmbuild),装 systemd 单元 + 专用
  系统账号 + /etc/webui4frpc 配置,非 root 运行 + 加固
- Windows: NSIS setup.exe,向导收集监听地址/凭据,注册卸载器 +
  防火墙放行(amd64/arm64)
- macOS: .pkg(在 Linux 上手工 xar+mkbom 组装 flat package),launchd
  开机自启 + env 配置文件(amd64/arm64)
- scripts/release.sh 纳入 build_installers.sh 阶段;README 增「安装」
  章节逐平台说明

已知限制:rpmbuild 无法在非 RHEL 主机交叉构建 aarch64(Debian rpm
只有 x86_64 构建 arch),aarch64 rpm 需在原生 ARM 机器构建;Linux/ARM
场景已由 deb-arm64 覆盖。
2026-08-28 08:36:55 +08:00
6ee901dc64 chore: 发布脚本 — 全平台构建打包 + gitcode release 资产上传
- scripts/release.sh: 前端构建 → 6 平台交叉编译 (linux/windows/darwin ×
  amd64/arm64) → tar.gz/zip 打包 → SHA256SUMS;子目录隔离同名二进制
- scripts/upload_assets.py: gitcode 两步上传流程
  (upload_url 签名 → urllib PUT 到 OBS),curl 会 401 但 urllib 干净成功
- 首次发布 v0.1.0: 6 个平台安装包 + SHA256SUMS 已上传,下载校验一致
2026-08-28 08:16:58 +08:00
27 changed files with 1741 additions and 6 deletions

3
.gitignore vendored
View File

@ -33,3 +33,6 @@ gui-test-screenshots/
# vendored deps (regenerable; keep out of git)
vendor/
.pi-glla/
# 发布产物(由 scripts/release.sh 生成)
dist/

View File

@ -72,8 +72,74 @@ rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
```
## 安装
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.2-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo systemctl restart webui4frpc
```
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.2-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo systemctl enable --now webui4frpc
```
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
### Windows
双击 `webui4frpc-0.1.2-windows-amd64-setup.exe`ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
- 开始菜单生成启动快捷方式与卸载项
- 自动添加防火墙入站规则
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.2-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.2-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
```bash
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
```
> 该 `.pkg` 未经 Apple 公证notarization。首次打开若被 Gatekeeper 拦下,
> 在「系统设置 → 隐私与安全」选择仍要打开。
### 免安装tar.gz / zip
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.2-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```
下载后可用 `SHA256SUMS` 校验完整性:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## 运行
从源码构建后直接跑:
```bash
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
```

View File

@ -25,6 +25,7 @@ import (
"webui4frpc/internal/process"
"webui4frpc/internal/render"
"webui4frpc/internal/store"
"webui4frpc/internal/version"
)
func main() {
@ -132,7 +133,7 @@ func main() {
}
var ring *cluster.Engine
ring = cluster.NewEngine(
selfID, selfID, *user, *pass, "0.1.0", nil,
selfID, selfID, *user, *pass, version.Version, nil,
&cluster.AppHandler{
LoadFn: func() (float64, float64) {
// NetPct: real NIC saturation from /proc/net/dev deltas vs
@ -533,7 +534,7 @@ func main() {
// CreateCluster so the node is at least functional as standalone.
func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) {
selfNode := ring.State().Nodes[0]
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: joinKey}
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: joinKey}
deadline := time.NewTimer(5 * time.Minute)
defer deadline.Stop()
ticker := time.NewTicker(10 * time.Second)
@ -593,7 +594,7 @@ func retryRejoinCached(ctx context.Context, ring *cluster.Engine, peersJSON stri
return
}
for _, p := range peers {
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: p.Key}
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: p.Key}
jc, cancel := context.WithTimeout(ctx, 8*time.Second)
err := ring.JoinRing(jc, p.Addr, ji)
cancel()

View File

@ -155,7 +155,9 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
}
// Non-leader neighbor death: continue to the next recipient.
}
e.inflight.clear()
// 全环遍历完毕,所有后继都不可达(或已全部尝试过)。
// 保持 inflight不清空让 WatchTokenLoss 超时后重发。
// 重发时 AliveSuccessor 重新计算,可能已被心跳复活。
return nil
}

View File

@ -9,6 +9,8 @@ import (
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
@ -18,6 +20,7 @@ import (
"webui4frpc/internal/cluster"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
"webui4frpc/internal/version"
)
//go:embed all:dist
@ -411,7 +414,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
}
resp := map[string]any{
"version": "0.1.0",
"version": version.Version,
"workDir": h.WorkDir,
"settings": settings,
"services": locals,
@ -538,11 +541,31 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
// 退出节点时让leader上邻居意识到当前环已经没有节点了".
//
// 同时心跳到达说明前驱还活着leader 应在地自己的拓扑中把它标记为
// alive。否则 forwardToNext 一次超时把前驱 MarkOffline 后就再没有
// 途径恢复它——前驱明明活着leader 却认为它 offline → AliveSuccessor
// 找不到后继,令牌冻结在 leader 手上。心跳是 leader 侧纠正误判的唯一
// 入口(因为心跳只有前驱发给 leader 这一条边)。
if r.Body == nil {
if s := h.Ring.State(); len(s.Nodes) <= 1 {
http.Error(w, "standalone node", http.StatusConflict)
return
}
// 心跳到达 = 前驱活着,标记 alive纠正 forwardToNext 的误判)
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
w.WriteHeader(http.StatusOK)
return
}
@ -552,6 +575,20 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "standalone node", http.StatusConflict)
return
}
// 同上:心跳到达 = 前驱活着
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
w.WriteHeader(http.StatusOK)
return
}

View File

@ -0,0 +1,12 @@
// Package version holds the single source of truth for the build version.
//
// Value is overridable at build time so release artifacts carry the real tag:
//
// go build -ldflags="-X webui4frpc/internal/version.Version=0.1.2" ./cmd/webui4frpc
//
// Kept in its own package because both cmd/ and internal/httpapi report it
// (main can't be imported, and httpapi must not depend on cmd).
package version
// Version is the running build's semantic version, without a leading "v".
var Version = "0.1.2"

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,8 @@
# webui4frpc runtime configuration
# 默认监听全部接口的 7500 端口;请修改密码后再投入生产。
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
# 可选:首次提供的 frpc 二进制路径(留空则从 Web UI 手动/自动安装)
# W4F_FRPC=/usr/local/bin/frpc

View File

@ -0,0 +1,25 @@
[Unit]
Description=webui4frpc - visual frpc controller (cluster node)
Documentation=https://gitcode.com/JianFeeeee/webui4frpc
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=webui4frpc
Group=webui4frpc
EnvironmentFile=/etc/webui4frpc/webui4frpc.env
ExecStart=/usr/bin/webui4frpc -addr ${W4F_ADDR} -user ${W4F_USER} -password ${W4F_PASSWORD} -workdir /var/lib/webui4frpc
Restart=always
RestartSec=5
LimitNOFILE=65535
# hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/webui4frpc
[Install]
WantedBy=multi-user.target

42
packaging/deb/postinst Executable file
View File

@ -0,0 +1,42 @@
#!/bin/sh
# webui4frpc deb/rpm 安装后脚本:建用户、建目录、装服务
set -e
if ! getent group webui4frpc >/dev/null 2>&1; then
groupadd --system webui4frpc
fi
if ! getent passwd webui4frpc >/dev/null 2>&1; then
useradd --system --gid webui4frpc \
--home-dir /var/lib/webui4frpc --no-create-home \
--shell /usr/sbin/nologin \
--comment "webui4frpc service account" webui4frpc
fi
mkdir -p /var/lib/webui4frpc
chown -R webui4frpc:webui4frpc /var/lib/webui4frpc
chmod 750 /var/lib/webui4frpc
chown root:webui4frpc /etc/webui4frpc/webui4frpc.env 2>/dev/null || true
chmod 640 /etc/webui4frpc/webui4frpc.env 2>/dev/null || true
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable webui4frpc.service || true
if [ -z "${DPKG_ROOT:-}" ]; then
systemctl restart webui4frpc.service || true
fi
fi
cat <<'EOF'
webui4frpc 已安装。
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
数据: /var/lib/webui4frpc
服务: systemctl status webui4frpc
界面: http://<本机地址>:7500
改完配置后执行: systemctl restart webui4frpc
EOF
exit 0

20
packaging/deb/postrm Executable file
View File

@ -0,0 +1,20 @@
#!/bin/sh
# webui4frpc deb 卸载后脚本purge 时清数据与账号
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
if [ "$1" = "purge" ]; then
rm -rf /var/lib/webui4frpc
rm -rf /etc/webui4frpc
if getent passwd webui4frpc >/dev/null 2>&1; then
userdel webui4frpc || true
fi
if getent group webui4frpc >/dev/null 2>&1; then
groupdel webui4frpc || true
fi
fi
exit 0

10
packaging/deb/prerm Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# webui4frpc deb 卸载前脚本:停服务、撤 enable
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl stop webui4frpc.service || true
systemctl disable webui4frpc.service || true
fi
exit 0

187
packaging/macos/build_pkg.sh Executable file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env bash
#
# 在 Linux 上手工组装 macOS .pkg (flat package)。
# macOS 的 pkgbuild/productbuild 不可用,因此用 xar + mkbom 手搓 flat package
#
# archive.xar
# ├── Distribution 安装器界面描述 (choices / title)
# └── webui4frpc.pkg/
# ├── PackageInfo 组件元数据 + postinstall 声明
# ├── Bom 文件清单 (mkbom 生成)
# ├── Payload 根文件系统 cpio.gz
# └── Scripts 安装脚本 cpio.gz
#
# 用法: build_pkg.sh <version> <arch:amd64|arm64> <binary> <outfile>
set -euo pipefail
VERSION="$1"; ARCH="$2"
# xar 与 cpio 都在临时目录里执行,输入输出必须先转成绝对路径
BINARY="$(cd "$(dirname "$3")" && pwd)/$(basename "$3")"
mkdir -p "$(dirname "$4")"
OUTFILE="$(cd "$(dirname "$4")" && pwd)/$(basename "$4")"
case "$ARCH" in
amd64) PKG_ARCH="x86_64" ;;
arm64) PKG_ARCH="arm64" ;;
*) echo "未知架构: $ARCH" >&2; exit 1 ;;
esac
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ROOT="$WORK/root"
SCRIPTS="$WORK/scripts"
FLAT="$WORK/flat"
PKGDIR="$FLAT/webui4frpc.pkg"
mkdir -p "$ROOT/usr/local/bin" "$ROOT/usr/local/etc/webui4frpc" "$SCRIPTS" "$PKGDIR"
# ---- payload 根文件系统 ----
install -m 0755 "$BINARY" "$ROOT/usr/local/bin/webui4frpc"
cat > "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env" <<'ENVEOF'
# webui4frpc runtime configuration (macOS)
# 请修改密码后再投入使用;改完执行:
# sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
ENVEOF
chmod 0644 "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env"
# ---- launchd plist ----
mkdir -p "$ROOT/Library/LaunchDaemons"
cat > "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist" <<'PLISTEOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jianfeeeee.webui4frpc</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/webui4frpc-launch</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/usr/local/var/log/webui4frpc.log</string>
<key>StandardErrorPath</key>
<string>/usr/local/var/log/webui4frpc.err.log</string>
<key>WorkingDirectory</key>
<string>/usr/local/var/lib/webui4frpc</string>
</dict>
</plist>
PLISTEOF
chmod 0644 "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist"
# launchd 不读 EnvironmentFile用小包装脚本 source 配置再 exec
cat > "$ROOT/usr/local/bin/webui4frpc-launch" <<'LAUNCHEOF'
#!/bin/sh
# 读取 /usr/local/etc/webui4frpc/webui4frpc.env 后启动 webui4frpc
set -e
ENV_FILE=/usr/local/etc/webui4frpc/webui4frpc.env
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
exec /usr/local/bin/webui4frpc \
-addr "$W4F_ADDR" \
-user "$W4F_USER" \
-password "$W4F_PASSWORD" \
-workdir /usr/local/var/lib/webui4frpc
LAUNCHEOF
chmod 0755 "$ROOT/usr/local/bin/webui4frpc-launch"
# ---- postinstall ----
cat > "$SCRIPTS/postinstall" <<'POSTEOF'
#!/bin/sh
set -e
mkdir -p /usr/local/var/lib/webui4frpc /usr/local/var/log
chmod 750 /usr/local/var/lib/webui4frpc
PLIST=/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist
chown root:wheel "$PLIST"
chmod 644 "$PLIST"
# 覆盖安装时先卸载旧的
launchctl bootout system "$PLIST" 2>/dev/null || true
launchctl bootstrap system "$PLIST" 2>/dev/null || \
launchctl load -w "$PLIST" 2>/dev/null || true
exit 0
POSTEOF
chmod 0755 "$SCRIPTS/postinstall"
# ---- Payload / Scripts (cpio.gz) ----
( cd "$ROOT" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
| gzip -9 > "$PKGDIR/Payload"
( cd "$SCRIPTS" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
| gzip -9 > "$PKGDIR/Scripts"
# ---- Bom ----
mkbom -u 0 -g 80 "$ROOT" "$PKGDIR/Bom"
NFILES=$(find "$ROOT" | wc -l)
INSTALL_KB=$(du -sk "$ROOT" | cut -f1)
# ---- PackageInfo ----
cat > "$PKGDIR/PackageInfo" <<INFOEOF
<?xml version="1.0" encoding="utf-8"?>
<pkg-info format-version="2"
identifier="com.jianfeeeee.webui4frpc"
version="$VERSION"
install-location="/"
auth="root"
relocatable="false"
overwrite-permissions="true">
<payload installKBytes="$INSTALL_KB" numberOfFiles="$NFILES"/>
<scripts>
<postinstall file="./postinstall"/>
</scripts>
<bundle-version/>
</pkg-info>
INFOEOF
# ---- Distribution ----
cat > "$FLAT/Distribution" <<DISTEOF
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>webui4frpc $VERSION</title>
<organization>com.jianfeeeee</organization>
<options customize="never" require-scripts="false" hostArchitectures="$PKG_ARCH"/>
<domains enable_localSystem="true" enable_anywhere="false" enable_currentUserHome="false"/>
<volume-check>
<allowed-os-versions><os-version min="11.0"/></allowed-os-versions>
</volume-check>
<welcome language="zh_CN" mime-type="text/plain">webui4frpc — 可视化 frpc 控制器
安装内容:
/usr/local/bin/webui4frpc 主程序(内嵌 Web 界面)
/usr/local/etc/webui4frpc/webui4frpc.env 配置文件
/Library/LaunchDaemons/…webui4frpc.plist 开机自启服务
安装完成后服务自动启动,访问 http://127.0.0.1:7500
默认凭据 admin/admin —— 请立即修改 webui4frpc.env 中的密码。</welcome>
<choices-outline>
<line choice="default">
<line choice="com.jianfeeeee.webui4frpc"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="com.jianfeeeee.webui4frpc" visible="false">
<pkg-ref id="com.jianfeeeee.webui4frpc"/>
</choice>
<pkg-ref id="com.jianfeeeee.webui4frpc"
version="$VERSION"
onConclusion="none">#webui4frpc.pkg</pkg-ref>
</installer-gui-script>
DISTEOF
# ---- 打成 xar (Distribution 必须是第一个成员) ----
rm -f "$OUTFILE"
( cd "$FLAT" && xar --compression none -cf "$OUTFILE" Distribution webui4frpc.pkg )
echo "已生成: $OUTFILE ($(du -h "$OUTFILE" | cut -f1))"

View File

@ -0,0 +1,82 @@
Name: webui4frpc
Version: %{?_w4f_version}%{!?_w4f_version:0.1.2}
Release: 1%{?dist}
Summary: Visual frpc controller with token-ring cluster
License: MIT
URL: https://gitcode.com/JianFeeeee/webui4frpc
BuildArch: %{?_w4f_arch}%{!?_w4f_arch:x86_64}
Requires(pre): shadow-utils
%{?systemd_requires}
BuildRequires: systemd-rpm-macros
%description
A web UI to manage FRP forwards across a cluster of nodes. Ships as a single
statically-linked binary with an embedded Vue frontend. Includes a token-ring
based cluster for distributed forward management, NIC network load sampling,
and audit-log CSV export.
%install
install -D -m 0755 %{_w4f_bin} %{buildroot}%{_bindir}/webui4frpc
install -D -m 0644 %{_w4f_service} %{buildroot}%{_unitdir}/webui4frpc.service
install -D -m 0640 %{_w4f_env} %{buildroot}%{_sysconfdir}/webui4frpc/webui4frpc.env
install -D -m 0644 %{_w4f_readme} %{buildroot}%{_docdir}/webui4frpc/README.md
install -d -m 0750 %{buildroot}%{_sharedstatedir}/webui4frpc
%pre
getent group webui4frpc >/dev/null || groupadd --system webui4frpc
getent passwd webui4frpc >/dev/null || \
useradd --system --gid webui4frpc \
--home-dir %{_sharedstatedir}/webui4frpc --no-create-home \
--shell /sbin/nologin \
--comment "webui4frpc service account" webui4frpc
exit 0
%post
%systemd_post webui4frpc.service
chown -R webui4frpc:webui4frpc %{_sharedstatedir}/webui4frpc || :
chown root:webui4frpc %{_sysconfdir}/webui4frpc/webui4frpc.env || :
cat <<'EOF'
webui4frpc 已安装。
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
数据: /var/lib/webui4frpc
服务: systemctl enable --now webui4frpc
界面: http://<本机地址>:7500
EOF
%preun
%systemd_preun webui4frpc.service
%postun
%systemd_postun_with_restart webui4frpc.service
if [ $1 -eq 0 ]; then
rm -rf %{_sharedstatedir}/webui4frpc
getent passwd webui4frpc >/dev/null && userdel webui4frpc || :
getent group webui4frpc >/dev/null && groupdel webui4frpc || :
fi
%files
%{_bindir}/webui4frpc
%{_unitdir}/webui4frpc.service
%dir %{_sysconfdir}/webui4frpc
%config(noreplace) %attr(0640,root,webui4frpc) %{_sysconfdir}/webui4frpc/webui4frpc.env
%dir %attr(0750,webui4frpc,webui4frpc) %{_sharedstatedir}/webui4frpc
%doc %{_docdir}/webui4frpc/README.md
%changelog
* Wed Sep 03 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.2-1
- 修复窄屏底部导航栏错位到顶部:顶栏的 backdrop-filter 会为
position:fixed 后代创建包含块,使 tab bar bottom:0 相对顶栏而
非视口定位。窄屏下关掉顶栏模糊,改用近实色底。
* Wed Sep 02 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.1-1
- 修复令牌环冻结:心跳到达时复活被误判 offline 的前驱;
全环无可达后继时保留 inflight leader 超时重发令牌。
- WebUI 窄屏适配:侧栅转底部 tab bar各页响应式重排。
* Fri Aug 28 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.0-1
- 首次发布per-forward worker 模型、NIC 负载采样、审计 CSV 导出、
session 登录与三级角色。

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

268
packaging/windows/README.md Normal file
View File

@ -0,0 +1,268 @@
# webui4frpc
> **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。支持多节点组成令牌环集群,协同分发转发任务。
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
## 特性
### 画布配置
- **Scratch 风格画布**本地转发项local与远程服务器remote可视化连线一个 local 可连多个 remote一个 remote 可连多个 local
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
- **画布冲突检查**:保存时自动检测远程端口/域名重复,避免配置冲突
- **边开关与分组**:画布边上可一键禁用/启用单条转发,可打分组标签(状态页按分组管理、一键启停整组)
- **Del 键/× 按钮删除**:选中连线按 Delete 键或点击端口标签红 × 删除,确认后标记脏
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
- **画布导入导出**:支持 JSON 信封备份/恢复(含 provenance 元数据)
### frpc 能力
- **代理类型**tcp完整、http/httpscustomDomains / subdomain / locations / basicAuth / headerRewrite
- **传输参数**useEncryption / useCompression / bandwidthLimit / poolCount / transport.protocoltcp/quic/kcp/websocket/ TLS
- **负载均衡与健康检查**lbGroup + healthCheck 渲染输出;状态页通过 frpc admin API 拉取 per-proxy 真实状态running / check failed / wait start …)
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc或手动指定二进制路径
### 集群模式(令牌环)
- **令牌环协议**:若干 webui4frpc 节点组成协作网络;令牌按固定顺序传递,每节点持完整集群信息,单轮即全网收敛
- **负载最低者摘取**:新转发任务附加在令牌中,由负载最低的节点自行摘取并创建 frpc worker
- 主负载信号:当前节点拥有的转发数(权重 ×100
- 次级信号:**NIC 网络接口利用率**(从 `/proc/net/dev` 增量 + `/sys/class/net/*/speed` 计算,排除了 lo/docker/veth 等虚拟接口)
- 同转发数节点间由内存占用 + 网络饱和度打破平局
- **per-forward 独立进程**:每条转发拥有独立的 frpc 配置文件和 frpc 进程worker 键 = `local~remote~port`),单条转发故障不影响兄弟,消除旧模型的多节点 `proxy already exists` 冲突
- **容错自愈**leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂leader 宕机 → 上邻居晋升为新 leader
- **宕机自动重联**:节点宕机重启后通过缓存的集群拓扑(对端地址 + 准入密钥)自动重联,显式脱离集群的节点不自动重联
- **准入密钥**:每节点有 nodeKey新节点加入需提供对端密钥密钥随令牌环交换全节点互通
- **集群二进制分发**:节点间优先互传 frpc 二进制,外部 URL 兜底
### 认证与权限
- **Session-cookie 登录 + Basic Auth**UI 使用 session-cookie支持登出API 保持 Basic Auth + Bearer API Key 兼容
- **三级角色**superadmin账号管理、admin全功能除账号、viewer只读状态/集群/审计导出)
- **用户管理**superadmin 角色创建/管理用户bcrypt 存储flag 内置账号为 system 只读
- **API Key**read / write / admin 三级 scope`w4f_` 前缀sha256 存储,创建时明文仅展示一次
- **审计导出**read 级 view 即可导出 CSV账号登录记录 / API Key 使用记录 / 集群操作日志 / worker 运行日志)
## 构建
### 后端Go 1.25+
```bash
go build -o webui4frpc ./cmd/webui4frpc
```
### 前端Node 20+
```bash
cd web && npm install && npm run build
```
前端产物在 `web/dist`,需拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制:
```bash
rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
```
一键全量构建dist 不进入 git编译前必须构建前端
```bash
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
```
## 安装
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.1-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo systemctl restart webui4frpc
```
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.1-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo systemctl enable --now webui4frpc
```
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
### Windows
双击 `webui4frpc-0.1.1-windows-amd64-setup.exe`ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
- 开始菜单生成启动快捷方式与卸载项
- 自动添加防火墙入站规则
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.1-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.1-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
```bash
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
```
> 该 `.pkg` 未经 Apple 公证notarization。首次打开若被 Gatekeeper 拦下,
> 在「系统设置 → 隐私与安全」选择仍要打开。
### 免安装tar.gz / zip
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.1-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```
下载后可用 `SHA256SUMS` 校验完整性:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## 运行
从源码构建后直接跑:
```bash
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
```
打开 <http://127.0.0.1:7500/> ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc或手动指定路径然后回到画布创建 local / remote 并连线、保存。
### 参数
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `-addr` | `127.0.0.1:7500` | 监听地址 |
| `-user` / `-password` | `admin` / `admin` | Basic 认证(自动同步为 system 管理员账号) |
| `-workdir` | `./webui-frpc` | 数据目录db / 配置 / 日志 / bin |
| `-bin` | 空 | 初始 frpc 二进制路径(可选,首次启动写入 settings |
| `-frpc` | 空 | worker 默认 frpc 路径settings 无值时的回退) |
| `-peer` | 空 | 集群对端地址 `host:port`(可重复,仅首次引导加入用) |
| `-join-key` | `$W4F_JOIN_KEY` | 集群准入密钥 = 对端节点的 nodeKey`-peer` 配合使用) |
环境变量 `W4F_HOST` 可覆盖节点对外可达的主机名Docker DNS / 通配监听场景用)。
### 启动行为
节点启动时的集群引导优先级:
1. **缓存拓扑优先**:读取持久化的 `ClusterPeers`(宕机前最后令牌周期保存的对端列表),逐个尝试 rejoin
2. **`-peer` 引导**:无缓存拓扑时,用 `-peer` + `-join-key` 加入指定对端10s 重试、5min 超时)
3. **新建集群**:无缓存无 `-peer``CreateCluster` 成为独立 leader
显式脱离集群detachAsStandalone会清除 `ClusterPeers` → 重启后不自动重联。
## 数据目录
```
<workdir>
├── manager.db # SQLitelocals / remotes / links / settings / users / api_keys
├── configs/<local>~<remote>~<port>.json # 每条转发的独立 frpc 配置per-forward 模型)
├── logs/<local>~<remote>~<port>.log # 每条转发的独立 worker 日志(按大小轮转)
└── bin/frpc-<v>/frpc # 一键安装的 frpc
```
## 架构
```
webui4frpc单二进制无 frp 依赖,前端 go:embed
├── cmd/webui4frpc 入口flag 解析 → store/process/cluster 装配 → HTTP 服务 + 生命周期
├── internal/
│ ├── store SQLite 持久化locals / remotes / links / settings / users / api_keys / sessions
│ ├── canvas 画布模型local / remote / link 多对多)
│ ├── render 渲染 frpc JSON 配置per-forward 单 proxy + 高级参数 + LB/健康检查)
│ ├── process worker 进程管理spawn/stop/restart/自愈/日志轮转worker 键 = local~remote~port
│ ├── install 一键下载官方 frpcGitHub Releases含解压安全防护
│ ├── cluster 令牌环协议ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发 / NIC 负载采样)
│ └── httpapi REST API + 静态资源auth/session / canvas / forwards / cluster / users / audit 导出)
└── web/ Vue 3 SPAElement Plus + VueFlow 画布 + Pinia
└── src/views/ 状态总览 / 连接配置(画布)/ 集群 / 设置 / 账号与密钥
```
## API
所有接口前缀 `/api/manager`。UI 使用 session-cookie 登录(`/login` `/logout`API 兼容 Basic Auth 或 Bearer API Key。权限分 read / write / admin 三级,角色 superadmin / admin / viewer 映射到不同 UI 可见性与操作能力。
| 类别 | 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- | --- |
| **状态** | GET | `/status` | read | 总览(版本/服务/节点/转发/profiles |
| **画布** | GET/PUT | `/canvas` | read/write | 读写完整画布 |
| | GET | `/canvas/export` | read | 导出画布备份 |
| | POST | `/canvas/import` | write | 导入画布 |
| **转发** | POST | `/forwards/start\|stop` | write | 启停单条转发 |
| | POST | `/forwards/group/start\|stop` | write | 启停整组 |
| | POST | `/forwards/assign` | write | 修改转发分组 |
| | POST | `/forwards/group/delete` | write | 删除分组 |
| **单资源** | PUT/DELETE | `/locals[/{name}]` | write | 增删 local |
| | PUT/DELETE | `/remotes[/{name}]` | write | 增删 remote |
| | POST/DELETE | `/links[/{id}]` | write | 增删 link |
| **Profile** | POST | `/profiles/{name}/start\|stop\|restart` | write | worker 启停重启 |
| | GET | `/profiles/{name}/config\|logs` | read | 配置/日志 |
| **设置** | GET/PUT | `/settings` | read/write | 运行策略 |
| | GET | `/binary/status` | read | frpc 路径 |
| | POST | `/binary/install` | write | 安装 frpc |
| **集群** | GET | `/cluster/ring` | read | 令牌环快照 |
| | POST | `/cluster/create` | write | 创建独立集群 |
| | POST | `/cluster/join-ring` | write | 加入集群(本节点发起) |
| | POST | `/cluster/join` | write | 接收新节点加入 |
| | POST | `/cluster/task` | write | 提交转发任务到环 |
| | POST | `/cluster/node-remove` | write | 移除节点 |
| | POST | `/cluster/token` | write | 令牌中继 + 心跳 |
| | GET | `/cluster/nodes` | read | 集群节点 + 缓存 |
| | GET/POST | `/cluster/cache` | read/write | 二进制缓存管理 |
| | GET | `/node/logs` | read | 本节点 worker 日志 |
| | GET | `/cluster/logs/export` | read | 导出全节点 worker 日志JSON bundle |
| **审计导出** | GET | `/audit/users.csv` | read | 账号清单 CSV含最后登录 |
| | GET | `/audit/apikeys.csv` | read | API Key 清单 CSV含最后使用/过期) |
| | GET | `/audit/cluster-log.csv` | read | 集群操作日志 CSV时间线 + 原始载荷) |
| | GET | `/audit/worker-logs.csv` | read | 全节点 worker 运行日志 CSV逐行展开 |
| | GET | `/frpc/{version}` | read | 节点间 frpc 二进制分发 |
| **认证** | POST | `/login` | 无 | 登录(表单凭证 → session cookie |
| | POST | `/logout` | 无 | 登出(清除 session |
| | GET | `/me` | read | 当前身份 |
| | GET/POST | `/users` | admin | 用户列表/创建 |
| | PUT/DELETE | `/users/{name}` | admin | 修改/删除用户 |
| | GET/POST | `/apikeys` | admin | API Key 列表/创建 |
| | DELETE | `/apikeys/{id}` | admin | 吊销 API Key |
> 集群管理 API 的详细说明(请求体、响应格式、错误码、示例)见 [docs/cluster-api.md](docs/cluster-api.md)。
## 集群模式
多个 webui4frpc 节点可组成令牌环集群协同工作:
- **创建集群**:首个节点 `CreateCluster` 成为 leader生成准入密钥nodeKey
- **加入集群**:在集群页复制对端的 nodeKey填入「加入集群」对话框对端地址 + 密钥)
- **任务分发**:在任意节点画布上配置 `localOnly=false` 的转发并保存 → 画布差异判断生成命令 → 令牌携带 → 负载最低节点摘取 → 拉起 frpc worker
- **容错**:节点宕机 → 自动检测 → 环自愈(跳过死亡节点 / leader failover重启 → 自动重联
- **退出集群**:在集群页「退出集群」→ 本节点脱离为 standalone不自动重联
集群页展示环拓扑self/leader 标记)、令牌轮次、节点负载、待办任务、活跃转发拓扑(含 owner 归属)、增量事件日志。
## 测试
```bash
# 后端
go test ./...
# 前端类型检查
cd web && npm run type-check
```

View File

@ -0,0 +1,162 @@
; webui4frpc Windows 安装程序 (NSIS)
;
; 由 scripts/build_installers.sh 调用,需在命令行传入:
; -DVERSION=0.1.0 -DARCH=amd64 -DBINSRC=<webui4frpc.exe 路径> -DOUTFILE=<输出 exe>
!include "MUI2.nsh"
!include "LogicLib.nsh"
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef ARCH
!define ARCH "amd64"
!endif
Name "webui4frpc ${VERSION}"
OutFile "${OUTFILE}"
Unicode true
RequestExecutionLevel admin
InstallDir "$PROGRAMFILES64\webui4frpc"
InstallDirRegKey HKLM "Software\webui4frpc" "InstallDir"
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "webui4frpc"
VIAddVersionKey "FileDescription" "Visual frpc controller with token-ring cluster"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "LegalCopyright" "JianFeeeee"
!define MUI_ABORTWARNING
!insertmacro MUI_PAGE_LICENSE "LICENSE.txt"
!insertmacro MUI_PAGE_DIRECTORY
Page custom ConfigPage ConfigPageLeave
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "English"
Var Dialog
Var LblAddr
Var TxtAddr
Var LblUser
Var TxtUser
Var LblPass
Var TxtPass
Var CfgAddr
Var CfgUser
Var CfgPass
Function .onInit
StrCpy $CfgAddr "0.0.0.0:7500"
StrCpy $CfgUser "admin"
StrCpy $CfgPass "admin"
FunctionEnd
Function ConfigPage
!insertmacro MUI_HEADER_TEXT "服务配置" "设置监听地址与登录凭据"
nsDialogs::Create 1018
Pop $Dialog
${If} $Dialog == error
Abort
${EndIf}
${NSD_CreateLabel} 0 0 100% 12u "监听地址 (host:port)"
Pop $LblAddr
${NSD_CreateText} 0 14u 100% 12u "$CfgAddr"
Pop $TxtAddr
${NSD_CreateLabel} 0 34u 100% 12u "管理员用户名:"
Pop $LblUser
${NSD_CreateText} 0 48u 100% 12u "$CfgUser"
Pop $TxtUser
${NSD_CreateLabel} 0 68u 100% 12u "管理员密码(请勿沿用默认值):"
Pop $LblPass
${NSD_CreatePassword} 0 82u 100% 12u "$CfgPass"
Pop $TxtPass
nsDialogs::Show
FunctionEnd
Function ConfigPageLeave
${NSD_GetText} $TxtAddr $CfgAddr
${NSD_GetText} $TxtUser $CfgUser
${NSD_GetText} $TxtPass $CfgPass
FunctionEnd
Section "webui4frpc" SecMain
SetOutPath "$INSTDIR"
File "/oname=webui4frpc.exe" "${BINSRC}"
File "README.md"
File "LICENSE.txt"
CreateDirectory "$INSTDIR\data"
; 启动脚本,承载安装时选择的配置
FileOpen $0 "$INSTDIR\start-webui4frpc.cmd" w
FileWrite $0 "@echo off$\r$\n"
FileWrite $0 "rem webui4frpc 启动脚本(由安装程序生成)$\r$\n"
FileWrite $0 "cd /d %~dp0$\r$\n"
FileWrite $0 'webui4frpc.exe -addr $CfgAddr -user $CfgUser -password $CfgPass -workdir "%~dp0data"$\r$\n'
FileClose $0
WriteRegStr HKLM "Software\webui4frpc" "InstallDir" "$INSTDIR"
WriteRegStr HKLM "Software\webui4frpc" "Version" "${VERSION}"
; 添加/删除程序
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"DisplayName" "webui4frpc ${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"Publisher" "JianFeeeee"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"URLInfoAbout" "https://gitcode.com/JianFeeeee/webui4frpc"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"NoRepair" 1
WriteUninstaller "$INSTDIR\uninstall.exe"
; 开始菜单
CreateDirectory "$SMPROGRAMS\webui4frpc"
CreateShortcut "$SMPROGRAMS\webui4frpc\webui4frpc.lnk" \
"$INSTDIR\start-webui4frpc.cmd" "" "$INSTDIR\webui4frpc.exe" 0
CreateShortcut "$SMPROGRAMS\webui4frpc\打开管理界面.lnk" \
"http://127.0.0.1:7500"
CreateShortcut "$SMPROGRAMS\webui4frpc\卸载 webui4frpc.lnk" "$INSTDIR\uninstall.exe"
; 防火墙放行(失败不阻断安装)
nsExec::ExecToLog 'netsh advfirewall firewall add rule name="webui4frpc" \
dir=in action=allow program="$INSTDIR\webui4frpc.exe" enable=yes profile=any'
SectionEnd
Section "Uninstall"
nsExec::ExecToLog 'netsh advfirewall firewall delete rule name="webui4frpc"'
Delete "$INSTDIR\webui4frpc.exe"
Delete "$INSTDIR\start-webui4frpc.cmd"
Delete "$INSTDIR\README.md"
Delete "$INSTDIR\LICENSE.txt"
Delete "$INSTDIR\uninstall.exe"
; data 目录保留(含数据库与日志),由用户手动删除
MessageBox MB_YESNO|MB_ICONQUESTION \
"是否同时删除数据目录 $INSTDIR\data数据库转发配置日志" \
IDNO KeepData
RMDir /r "$INSTDIR\data"
KeepData:
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\webui4frpc\*.lnk"
RMDir "$SMPROGRAMS\webui4frpc"
DeleteRegKey HKLM "Software\webui4frpc"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc"
SectionEnd

131
scripts/build_installers.sh Executable file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env bash
#
# 从 release.sh 的交叉编译产物构建原生安装包:
# - linux amd64/arm64 → .deb
# - linux amd64/arm64 → .rpm
# - windows amd64/arm64 → NSIS .exe 安装器
# - darwin amd64/arm64 → macOS .pkg
#
# 用法: build_installers.sh <version> [arch...]
# arch 默认: linux amd64 arm64
#
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
set -euo pipefail
VERSION="${1:-0.1.2}"
VERSION="${VERSION#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
PKG="$ROOT/packaging"
# 工具检测(缺工具则给出明确提示后跳过该平台)
have() { command -v "$1" >/dev/null 2>&1; }
build_deb() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/linux-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 deb"; return; }
local deb=/tmp/w4f-deb-build; rm -rf "$deb"
mkdir -p "$deb/DEBIAN" "$deb/usr/bin" "$deb/etc/webui4frpc" \
"$deb/lib/systemd/system" "$deb/usr/share/doc/webui4frpc"
# control
cat > "$deb/DEBIAN/control" <<EOF
Package: webui4frpc
Version: $VERSION
Section: net
Priority: optional
Architecture: $arch
Maintainer: JianFeeeee <jianfeeeee@gitcode>
Description: visual frpc controller with token-ring cluster
A web UI to manage FRP forwards across a cluster of nodes. Single
statically-linked binary with an embedded Vue frontend. Includes a
token-ring based cluster for distributed forward management, NIC network
load sampling, and audit-log CSV export.
Homepage: https://gitcode.com/JianFeeeee/webui4frpc
EOF
install -m 0755 "$bindir/webui4frpc" "$deb/usr/bin/webui4frpc"
install -m 0644 "$PKG/common/webui4frpc.service" "$deb/lib/systemd/system/webui4frpc.service"
install -m 0640 "$PKG/common/webui4frpc.env" "$deb/etc/webui4frpc/webui4frpc.env"
install -m 0644 "$ROOT/README.md" "$deb/usr/share/doc/webui4frpc/README.md"
cat > "$deb/usr/share/doc/webui4frpc/copyright" <<'EOF'
webui4frpc - visual frpc controller
Copyright (C) 2026 JianFeeeee
SPDX-License-Identifier: MIT
EOF
install -m 0755 "$PKG/deb/postinst" "$deb/DEBIAN/postinst"
install -m 0755 "$PKG/deb/prerm" "$deb/DEBIAN/prerm"
install -m 0755 "$PKG/deb/postrm" "$deb/DEBIAN/postrm"
echo "/etc/webui4frpc/webui4frpc.env" > "$deb/DEBIAN/conffiles"
( cd "$deb" && find usr etc lib -type f -exec md5sum {} \; > DEBIAN/md5sums )
dpkg-deb -b --root-owner-group "$deb" "$OUT/webui4frpc-${VERSION}-linux-${arch}.deb" >/dev/null
echo " ✔ .deb linux/$arch"
}
build_rpm() {
local arch="$1" rarch key="$2" bindir
case "$arch" in amd64) rarch=x86_64;; arm64) rarch=aarch64;; *) return;; esac
bindir="$OUT/bin/linux-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 rpm"; return; }
local R=/tmp/w4f-rpmbuild; rm -rf "$R"; mkdir -p "$R"/{BUILD,RPMS,SPECS,SOURCES}
rpmbuild -bb "$PKG/rpm/webui4frpc.spec" --nodeps --target "$rarch" \
--define "_topdir $R" \
--define "_w4f_version $VERSION" \
--define "_w4f_arch $rarch" \
--define "_w4f_bin $bindir/webui4frpc" \
--define "_w4f_service $PKG/common/webui4frpc.service" \
--define "_w4f_env $PKG/common/webui4frpc.env" \
--define "_w4f_readme $ROOT/README.md" \
--define "_unitdir /usr/lib/systemd/system" \
--define "_sharedstatedir /var/lib" >/tmp/w4f-rpm.log 2>&1 || true
cp "$R"/RPMS/$rarch/*.rpm "$OUT/webui4frpc-${VERSION}-linux-${rarch}.rpm" 2>/dev/null \
&& echo " ✔ .rpm linux/$rarch" || { echo " ! rpm 失败: $(tail -3 /tmp/w4f-rpm.log)"; }
}
build_nsis() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/windows-$arch"
[ -f "$bindir/webui4frpc.exe" ] || { echo " ! 缺二进制 windows-$arch,跳过 NSIS"; return; }
[ -f "$PKG/windows/LICENSE.txt" ] || { echo " ! 缺 $PKG/windows/LICENSE.txt"; return; }
cp -f "$ROOT/README.md" "$PKG/windows/README.md"
makensis "-DVERSION=$VERSION" "-DARCH=$arch" \
"-DBINSRC=$bindir/webui4frpc.exe" \
"-DOUTFILE=$OUT/webui4frpc-${VERSION}-windows-${arch}-setup.exe" \
"$PKG/windows/webui4frpc.nsi" >/tmp/w4f-nsis.log 2>&1 \
&& echo " ✔ NSIS setup.exe windows/$arch" \
|| { echo " ! NSIS 失败: $(tail -4 /tmp/w4f-nsis.log)"; }
}
build_pkg() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/darwin-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 darwin-$arch,跳过 pkg"; return; }
"$PKG/macos/build_pkg.sh" "$VERSION" "$arch" \
"$bindir/webui4frpc" \
"$OUT/webui4frpc-${VERSION}-darwin-${arch}.pkg" >/dev/null \
&& echo " ✔ .pkg darwin/$arch" \
|| echo " ! macOS pkg 失败 darwin/$arch"
}
ARCHES=("${@:2}")
[ ${#ARCHES[@]} -eq 0 ] && ARCHES=(amd64 arm64)
echo "==> 原生安装包 (v$VERSION):"
for arch in "${ARCHES[@]}"; do
key="linux-$arch"
if have dpkg-deb; then build_deb "$arch" "$key"; else echo " . dpkg-deb 不可用,跳过 deb"; fi
if have rpmbuild; then build_rpm "$arch" "$key"; else echo " . rpmbuild 不可用,跳过 rpm"; fi
done
if have makensis; then
for arch in "${ARCHES[@]}"; do build_nsis "$arch" "windows-$arch"; done
else
echo " . makensis 不可用,跳过 windows setup.exe"
fi
if have xar && have mkbom && have cpio; then
for arch in "${ARCHES[@]}"; do build_pkg "$arch" "darwin-$arch"; done
else
echo " . xar/mkbom 不可用,跳过 macOS pkg"
fi
echo ""
echo "==> 安装包产物:"
ls -lh "$OUT"/*.deb "$OUT"/*.rpm "$OUT"/*-setup.exe "$OUT"/*.pkg 2>/dev/null || true

150
scripts/release.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
#
# webui4frpc 多平台发布脚本
#
# 用法:
# ./scripts/release.sh [version] # 默认 version=0.1.2
#
# 行为:
# 1. 构建前端 (web/dist) 并同步到 internal/httpapi/dist (go:embed 用)
# 2. 交叉编译全平台单二进制 (linux/windows/darwin × amd64/arm64)
# 3. 打包各平台安装包:
# - linux/darwin: tar.gz (二进制 + README + systemd 模板)
# - windows: zip (exe + README)
# 4. 生成 SHA256SUMS
# 5. 输出到 dist/artifacts/<version>/
#
# 环境变量:
# GOPROXY_OVERRIDE 覆盖 go module 代理 (默认 goproxy.cn,direct, 走本地 7890 代理)
# NO_BUILD_FRONTEND 设为 1 跳过前端构建 (用现有 dist)
set -euo pipefail
VERSION="${1:-0.1.2}"
VERSION="${VERSION#v}" # 去掉可能的前导 v
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
GOFLAGS_ENV=""
# ---- 0. Go module 代理 (内网需走本地代理访问 goproxy.cn) ----
if [ -n "${GOPROXY_OVERRIDE:-}" ]; then
GOFLAGS_ENV="GOPROXY=${GOPROXY_OVERRIDE}"
else
GOFLAGS_ENV="GOPROXY=https://goproxy.cn,direct"
fi
# 若本地 7890 代理存在则导出 (模块下载走代理)
if curl -s -o /dev/null -m 2 -x http://127.0.0.1:7890 https://goproxy.cn >/dev/null 2>&1; then
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
fi
echo "==> 版本: $VERSION"
mkdir -p "$OUT"
# ---- 1. 构建前端 ----
if [ "${NO_BUILD_FRONTEND:-0}" != "1" ]; then
echo "==> 构建前端"
( cd "$ROOT/web" && npm run build )
rm -rf "$ROOT/internal/httpapi/dist"
cp -r "$ROOT/web/dist" "$ROOT/internal/httpapi/dist"
else
echo "==> 跳过前端构建 (用现有 dist)"
fi
# ---- 2. 交叉编译全平台 ----
# target 格式: os arch 扩展名 [tar|zip]
TARGETS=(
"linux amd64" "linux arm64"
"windows amd64" "windows arm64"
"darwin amd64" "darwin arm64"
)
declare -A PLATFORM_EXT=(
[linux-amd64]=tgz [linux-arm64]=tgz
[darwin-amd64]=tgz [darwin-arm64]=tgz
[windows-amd64]=zip [windows-arm64]=zip
)
declare -A PLATFORM_BIN=(
[linux-amd64]=webui4frpc [linux-arm64]=webui4frpc
[darwin-amd64]=webui4frpc [darwin-arm64]=webui4frpc
[windows-amd64]=webui4frpc.exe [windows-arm64]=webui4frpc.exe
)
for target in "${TARGETS[@]}"; do
os="${target%% *}"; arch="${target##* }"
key="$os-$arch"
bin="${PLATFORM_BIN[$key]}"
# 每个平台编译到独立子目录,避免 darwin/linux 同名二进制互相覆盖
bindir="$OUT/bin/$key"
mkdir -p "$bindir"
echo "==> 编译 $os/$arch"
env $GOFLAGS_ENV CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath \
-ldflags="-s -w -X webui4frpc/internal/version.Version=$VERSION" \
-o "$bindir/$bin" "$ROOT/cmd/webui4frpc"
done
# ---- 3. 打包各平台 ----
# 整理 systemd 模板
cat > "$OUT/webui4frpc.service.tpl" <<'TPL'
[Unit]
Description=webui4frpc - visual frpc controller (cluster node)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/webui4frpc/webui4frpc -addr 0.0.0.0:7500 -user admin -password admin -workdir /opt/webui4frpc/data
Restart=always
RestartSec=5
LimitNOFILE=65535
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
TPL
for target in "${TARGETS[@]}"; do
os="${target%% *}"; arch="${target##* }"
key="$os-$arch"
bin="${PLATFORM_BIN[$key]}"
ext="${PLATFORM_EXT[$key]}"
base="webui4frpc-${VERSION}-${os}-${arch}"
echo "==> 打包 $key -> $base.$ext"
cp "$ROOT/README.md" "$OUT/README.md"
bindir="$OUT/bin/$key"
# staging 目录: 把二进制 + README (+systemd) 放一起再一次性打包
stage="$OUT/.stage/$key"
rm -rf "$stage"; mkdir -p "$stage"
cp "$bindir/$bin" "$stage/$bin"
cp "$OUT/README.md" "$stage/README.md"
if [ "$ext" = "zip" ]; then
( cd "$stage" && zip -q "$OUT/$base.zip" "$bin" README.md )
else
cp "$OUT/webui4frpc.service.tpl" "$stage/"
( cd "$stage" && tar czf "$OUT/$base.tar.gz" "$bin" README.md webui4frpc.service.tpl )
fi
done
rm -rf "$OUT/.stage"
# ---- 4. 校验和 ----
# 先生成免安装包的校验和;原生安装包构建完后在 4.6 统一重算,
# 保证 SHA256SUMS 覆盖 deb/rpm/setup.exe/pkg否则用户校验会 "no file was verified")。
# ---- 4.5 原生安装包 (deb/rpm/setup.exe/pkg) ----
if [ "${NO_INSTALLERS:-0}" != "1" ]; then
echo ""
echo "==> 构建原生安装包"
"$ROOT/scripts/build_installers.sh" "$VERSION"
fi
# ---- 4.6 校验和(覆盖全部产物)----
echo ""
echo "==> 生成 SHA256SUMS"
( cd "$OUT" && sha256sum *.tar.gz *.zip *.deb *.rpm *-setup.exe *.pkg 2>/dev/null | tee SHA256SUMS )
# ---- 5. 汇总 ----
echo ""
echo "==> 发布产物位于: $OUT"
ls -lh "$OUT"
echo ""
echo "==> 完成"

88
scripts/upload_assets.py Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""上传 release 资产到 gitcode两步取签名 URL → PUT 到 OBS
用法: upload_assets.py <version> <token> [file...]
不传 file 时上传该版本目录下全部发布产物:
tar.gz / zip / deb / rpm / pkg / -setup.exe / SHA256SUMS
"""
import json
import os
import sys
import urllib.request
import urllib.parse
REPO = "JianFeeeee/webui4frpc"
API = "https://gitcode.com/api/v5/repos"
def get_upload_url(version: str, token: str, filename: str) -> tuple[str, dict]:
q = urllib.parse.urlencode({"file_name": filename})
url = f"{API}/{REPO}/releases/v{version}/upload_url?{q}"
req = urllib.request.Request(url, headers={"private-token": token})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
return data["url"], data.get("headers", {})
def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
size = os.path.getsize(path)
with open(path, "rb") as f:
body = f.read()
req = urllib.request.Request(url, data=body, method="PUT")
for k, v in headers.items():
req.add_header(k, v)
req.add_header("Content-Length", str(size))
try:
with urllib.request.urlopen(req, timeout=300) as r:
return r.status, r.read().decode("utf-8", "replace")[:300]
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")[:300]
# 发布产物后缀:免安装包 + 原生安装包。
# 注意 -setup.exe 而不是裸 .exe避开 bin/ 里的裸二进制。
ARTIFACT_SUFFIXES = (
".tar.gz", ".zip", # 免安装
".deb", ".rpm", ".pkg", # linux / macOS 安装包
"-setup.exe", # windows 安装器
)
def is_artifact(name: str) -> bool:
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
version, token = sys.argv[1], sys.argv[2]
outdir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dist", "artifacts", version,
)
files = sys.argv[3:] or sorted(f for f in os.listdir(outdir) if is_artifact(f))
failed = 0
for name in files:
path = os.path.join(outdir, name)
if not os.path.isfile(path):
print(f"skip (missing): {name}")
continue
print(f"==> {name} ({os.path.getsize(path)/1048576:.1f} MiB)")
try:
url, headers = get_upload_url(version, token, name)
except Exception as e: # noqa: BLE001
print(f" upload_url FAILED: {e}")
failed += 1
continue
status, body = put_file(url, headers, path)
ok = 200 <= status < 300
print(f" PUT -> {status} {'OK' if ok else body}")
if not ok:
failed += 1
print(f"\n{'ALL OK' if failed == 0 else f'{failed} FAILED'}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
</head>

View File

@ -363,4 +363,149 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
.breadcrumb { padding: 12px 16px 6px; }
.content { padding: 8px 14px 32px; }
}
/* ---------- narrow screens: sidebar becomes a bottom tab bar ----------
窄屏(≤ 720px下 210px 侧栅会吃掉手机一半宽度,改为:
- shell 竖向排列,侧栅沉到底部变成固定 tab bar图标在上文字在下
- 品牌区/身份区/主题切换折叠进顶部窄条,不占垂直空间
- 内容区预留底部安全区 + tab bar 高度,避开 iOS 手势条
- 每个 tab 至少 44px 高(触控目标下限) */
@media (max-width: 720px) {
.app-shell {
flex-direction: column;
}
/* 顶部窄条logo + 身份 + 主题,横向排一行 */
.sidebar {
flex: 0 0 auto;
width: 100%;
order: -1;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 10px 12px;
overflow: hidden;
/* 兵家必争:任何子项算错都不得弄出横向滚动条 */
overflow-x: hidden;
border-right: none;
border-bottom: 1px solid var(--w4f-line);
padding-top: max(10px, env(safe-area-inset-top));
/* 关键backdrop-filter 会为 position:fixed 后代创建【包含块】
(与 transform/filter 同理)。若保留它,下面 .sb-nav 的
bottom:0 就是相对这条顶栏定位而非视口,底部 tab bar 会贴在
顶栏下沿、看起来固定在屏幕顶部。因此窄屏必须关掉顶栏的玻璃
模糊,改用接近实色的底,模糊效果留在 .sb-nav 自己身上
(元素自身的 backdrop-filter 不影响它自己的定位)。 */
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: color-mix(in srgb, var(--w4f-card-solid) 92%, transparent);
}
.sb-brand {
padding: 0;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* 品牌文字必须可收缩“webui4frpc”是不可断行单词不给 min-width:0
+ overflow:hidden 的话它的 flex 自动最小尺寸等于 min-content≨85px
顶栏就拿不出空间给右侧身份区,溢出为横向滚动。 */
.sb-brand-text { min-width: 0; overflow: hidden; }
.sb-brand-text b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-logo { width: 30px; height: 30px; border-radius: 10px; }
.sb-brand-text b { font-size: 14.5px; }
.sb-brand-text small { display: none; }
/* 导航从侧栅里抽出来,固定到底部 */
.sb-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
flex: none;
flex-direction: row;
gap: 0;
padding: 4px 4px max(4px, env(safe-area-inset-bottom));
background: var(--w4f-card);
border-top: 1px solid var(--w4f-line);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
box-shadow: 0 -4px 20px rgb(0 0 0 / 6%);
}
.sb-i {
flex: 1 1 0;
min-width: 0;
min-height: 44px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 2px;
border-radius: 11px;
font-size: 10.5px;
font-weight: 600;
text-align: center;
}
.sb-i .label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-i.active {
/* 底部 tab 不用渐变底,只高亮图标与文字(避免拥挤) */
background: var(--w4f-primary-50);
border-color: transparent;
box-shadow: none;
}
/* 身份与主题折进顶条右侧。
不再用 vw 限宽vw 含垂直滚动条宽度,会比实际可用内容宽大;而且
max-width 只封顶盒子,内部 flex:0 0 auto 的徒章/按钮仍会撑出去。
改为全链路可收缩min-width:0 + overflow:hidden由 flex 自行分配。 */
.sb-foot {
margin-top: 0;
padding-top: 0;
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
}
.sb-identity {
margin-bottom: 0;
padding: 5px 8px;
font-size: 11.5px;
flex: 0 1 auto;
min-width: 0;
max-width: none;
overflow: hidden;
}
.sb-identity .id-name { min-width: 0; max-width: none; }
.sb-theme { margin-bottom: 0; flex: 0 0 auto; }
.sb-theme .sw { width: 22px; height: 22px; border-radius: 7px; }
.main { height: auto; flex: 1; min-height: 0; }
.breadcrumb { padding: 10px 14px 4px; font-size: 14px; }
.content {
padding: 6px 12px 24px;
/* 底部 tab bar44px + padding+ 安全区 */
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
/* 极窄(≤ 380pxiPhone SE / 小屏安卓):进一步压缩顶条。
角色徒章是 flex:0 0 auto 不可收缩的“超级管理员”≨70px极窄下隐去
用户名与退出按钮保留。 */
@media (max-width: 380px) {
.sb-brand-text b { font-size: 13px; }
.sb-identity { padding: 4px 7px; }
.sb-identity .id-level { display: none; }
.sb-i { font-size: 9.5px; }
.content { padding-left: 10px; padding-right: 10px; }
}
</style>

View File

@ -1053,4 +1053,47 @@ onBeforeUnmount(() => {
cursor: grabbing;
}
}
/* ---------- narrow screens ----------
画布本质上需要宽屏拖拽,窄屏下只做到“可用”:
- toolbar 七个按钮在 375px 下会溢出,改成标题占行 + 按钮换行平铺
- 画布高度给个下限,否则在短屏上只剩一条缝
- handle 抬到 16px手指命中 12px 圆点几乎不可能) */
@media (max-width: 720px) {
.canvas-editor { gap: 10px; }
.toolbar {
padding: 11px 12px;
row-gap: 8px;
}
.toolbar-left,
.toolbar-right {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-title {
width: 100%;
margin-right: 0;
font-size: 15.5px;
}
.toolbar .btn {
flex: 1 1 auto;
min-height: 38px;
padding: 6px 10px;
}
.save-hint { width: 100%; }
.flow-wrap { min-height: 62vh; }
/* 触控:连接点与边标签放大,手指才点得到 */
:deep(.vue-flow__handle) {
width: 16px;
height: 16px;
}
:deep(.vue-flow__edge-label) {
padding: 3px 9px;
font-size: 12.5px;
}
}
</style>

View File

@ -560,4 +560,78 @@ onBeforeUnmount(() => {
/* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
.form-hint { font-size: 12px; color: $color-text-muted; }
/* ---------- narrow screens ----------
窄屏下的主要问题:
- ring-node 的 min-width:168px 与环形箭头在 375px 下会横向溢出,且节点横排无意义
- nodeKey 是 32 位 hex单行放不下会撑宽容器
- log-row 的 lg-detail max-width:60% + 不换行,窄屏几乎全被省略号吃掉
- toolbar / 对话框表单同 StatusView 的问题 */
@media (max-width: 720px) {
.cluster-page { gap: 10px; }
.section { padding: 14px 14px; }
.hero { padding: 14px 15px; gap: 12px; }
.page-title { font-size: 16.5px; }
/* nodeKey 允许折行,不再撑宽容器 */
.nk-value {
font-size: 11px;
word-break: break-all;
white-space: normal;
max-width: 100%;
}
.nk-label { white-space: normal; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
/* 工具栏按钮平分整行 */
.toolbar { padding: 11px 13px; gap: 8px; }
.toolbar > .w4f-btn { flex: 1 1 auto; min-height: 40px; }
.act-hint { width: 100%; }
/* 环拓扑改竖向:节点卡占整宽,箭头旋转 90° 指下 */
.ring-line { flex-direction: column; align-items: stretch; gap: 6px; }
.ring-node { min-width: 0; width: 100%; padding: 11px 12px; }
.rn-id { font-size: 12.5px; word-break: break-all; }
.ring-arrow {
align-self: center;
transform: rotate(90deg);
font-size: 15px;
line-height: 1;
}
/* 日志:详情另起一行完整显示,不再被省略号截掉 */
.log-row { row-gap: 3px; font-size: 11.5px; }
.lg-detail {
margin-left: 0;
width: 100%;
max-width: 100%;
text-align: left;
white-space: normal;
word-break: break-all;
}
.log-list { max-height: 260px; }
/* 任务/拓扑行:允许折行,归属另起一行 */
.task-row, .topo-row { flex-wrap: wrap; row-gap: 4px; font-size: 12.5px; }
.tp-owner { margin-left: 0; width: 100%; }
.title-actions { margin-left: 0; width: 100%; }
.title-actions > .w4f-btn { flex: 1 1 0; min-height: 36px; }
.section-title { flex-wrap: wrap; row-gap: 8px; }
/* 对话框表单:标签在上、输入在下 */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
.kpis { grid-template-columns: minmax(0, 1fr); }
.page-title { font-size: 15.5px; }
}
</style>

View File

@ -321,4 +321,21 @@ onMounted(load)
font-size: $font-size-sm;
color: $color-text-muted;
}
/* ---------- narrow screens ----------
binary-row 把输入框与安装按钮横排,窄屏下路径输入框被挤到只剩几十像素;
setting-row 的 space-between 在窄屏也会把说明文字与开关挤在一起。 */
@media (max-width: 720px) {
.binary-row {
flex-direction: column;
align-items: stretch;
}
.binary-row .btn { min-height: 40px; justify-content: center; }
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>

View File

@ -638,4 +638,91 @@ onBeforeUnmount(() => {
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
}
/* ---------- narrow screens ----------
窄屏下的主要问题:
- card-grid 的 minmax(320px) 在 375px 屏上会溢出(加上内边距超宽)
- topbar 的 space-between 把标题和按钮挤在一行
- fwd-head 一行塞下 7~8 个元素(类型/点/路由/协议/分组/归属/按钮)
- 分组标题的三个按钮 margin-left:auto 后被挤成细条
- 触控目标small 按钮)低于 44px */
@media (max-width: 720px) {
.status-page { gap: 10px; }
/* 标题与工具栏换行,按钮占整行平分 */
.topbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px;
}
.tb-right { width: 100%; }
.tb-right > .w4f-btn { flex: 1 1 0; min-height: 40px; }
/* KPI 固定两列auto-fit 在窄屏会变单列,四张卡拉得太长) */
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* 卡片网格改单列:消除 320px 最小宽导致的横向溢出 */
.card-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
/* 分组标题:标题一行,操作按钮另起一行平分 */
.section-title {
flex-wrap: wrap;
row-gap: 8px;
}
.grp-actions {
margin-left: 0;
width: 100%;
gap: 6px;
}
.grp-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; font-size: 12px; }
/* 转发卡:路由占整行,其余属性换行,按钮另起一行 */
.fwd-card { padding: 11px 12px; }
.fwd-head { row-gap: 7px; }
.fwd-route {
order: -1;
width: 100%;
margin-right: 0;
flex-wrap: wrap;
row-gap: 2px;
font-size: 13px;
}
.fwd-owner { max-width: 100%; }
.fwd-actions {
width: 100%;
margin-top: 2px;
}
.fwd-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
/* 远程节点卡:名称占行,操作按钮平分 */
.node-card { padding: 14px 15px; }
.nc-name { width: 100%; margin-right: 0; }
.nc-actions { width: 100%; }
.nc-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
.nc-cluster-note { max-width: 100%; }
.meta-pill { max-width: 100%; }
.np-err { max-width: 45%; }
/* 对话框表单:标签在上、输入在下(窄屏下 72px 标签挤死输入框) */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
/* 极窄KPI 也降为单列,否则数字会被压断 */
.kpis { grid-template-columns: minmax(0, 1fr); }
.fwd-route { font-size: 12.5px; }
.page-title { font-size: 16px; }
}
</style>

View File

@ -362,4 +362,37 @@ const onDeleteKey = async (k: ApiKey) => {
border-radius: 4px;
font-size: 12px;
}
/* ---------- narrow screens ----------
两张 el-table 列实宽加起来 ~900px窄屏必然需要横向滚动
Element Plus 自己会处理表内滚动,但卡片内边距与标题行需要压缩,
否则“导出 CSV / 新建”两个按钮会把标题挤出容器。 */
@media (max-width: 720px) {
.users-view { gap: 14px; }
.card { padding: 13px 13px; border-radius: 14px; }
.card-h {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
.card-h .h-actions {
display: flex;
gap: 8px;
}
/* Element 按钮在窄屏平分整行,并抬到 40px 触控高度 */
.card-h .h-actions :deep(.el-button) {
flex: 1 1 0;
min-height: 40px;
margin-left: 0;
}
/* 表格横向滚动时给个视觉提示,并保证不把卡片撑宽 */
:deep(.el-table) {
font-size: 12.5px;
}
:deep(.el-table__body-wrapper) {
-webkit-overflow-scrolling: touch;
}
}
</style>