mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus) - 画布多对多连线,渲染 tcp/udp/http/https frpc 配置 - worker 进程管理:自愈、日志轮转、崩溃退避重启 - frpc 一键安装 (GitHub Releases) + 手动指定路径 - 三页 UI:状态(默认)/连接配置/设置 - 状态页实时节点/转发状态,节点可增删改启停 - 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存 - backend 单测覆盖 store/render/process/httpapi/install - plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 前端构建产物(internal/httpapi/dist 供 Go embed,需提交以便开箱编译)
|
||||
web/dist/
|
||||
|
||||
# Go 二进制
|
||||
*.exe
|
||||
*.test
|
||||
webui-frpc
|
||||
webui4frpc
|
||||
|
||||
# 运行时数据
|
||||
*.db
|
||||
data/
|
||||
|
||||
# 编辑器 / 系统
|
||||
.DS_Store
|
||||
*.log
|
||||
109
FRPC_FEATURES_AUDIT.md
Normal file
109
FRPC_FEATURES_AUDIT.md
Normal file
@ -0,0 +1,109 @@
|
||||
# webui4frpc 对 frpc 高级功能支持审查报告
|
||||
|
||||
> 审查基准:frp v0.71 的 frpc 配置能力(官方文档支持的 proxy 类型、认证、传输、插件等)。
|
||||
> 审查范围:当前 webui4frpc 的画布数据模型(Local/Remote/Link)、渲染器(internal/render)能表达和输出什么。
|
||||
|
||||
## 一、总体结论
|
||||
|
||||
当前实现聚焦「最小可用」:**TCP/HTTP 单端口转发 + token 认证** 已打通并验证(多个远程节点、多对多连线、真实连通)。但 frpc 的**大部分高级能力尚未建模**,且**已有能力因模型缺字段而不可配置**。适合作为 1.0 基线,但要覆盖更广使用场景需系统性扩展。
|
||||
|
||||
## 二、能力支持矩阵
|
||||
|
||||
### 2.1 代理类型(proxy type)
|
||||
|
||||
| 类型 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| tcp | ✅ | 完整支持(LocalIP/LocalPort/RemotePort) |
|
||||
| udp | 🟡 部分 | 渲染支持,但协处理器(KCP/加密等)未建模 |
|
||||
| http | 🟡 部分 | 支持 customDomains 默认域名,但无法配置 locations/headers/负载均衡 |
|
||||
| https | 🟡 部分 | 同 http,未建模证书/SNI |
|
||||
| stcp | ❌ | 加密点对点穿透完全不支持 |
|
||||
| sudp | ❌ | 同 stcp |
|
||||
| xtcp | ❌ | P2P TCP 模拟(依赖 kcp-go),不支持 |
|
||||
| tcpmux | ❌ | HTTP/2 多路复用(multiplexer/http2),不支持 |
|
||||
|
||||
### 2.2 认证(auth)
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 服务端 token | ✅ | Remote 有 Token 字段 |
|
||||
| OIDC | ❌ | frpc 支持 openid connect(coreos/go-oidc),未建模 |
|
||||
| TLS 客户端证书 | ❌ | 未建模 |
|
||||
|
||||
### 2.3 传输(transport)
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| tcp / kcp / websocket / quic | 🟡 | Remote 无 protocol 字段,无法选择 |
|
||||
| 加密 / 压缩(useEncryption/useCompression) | ❌ | 未建模 |
|
||||
| 带宽限制(bandwidthLimit) | ❌ | 未建模 |
|
||||
| 连接池(poolCount) | ❌ | 未建模 |
|
||||
| TLS 传输(transport.tls.enable) | ❌ | 未建模 |
|
||||
| Connect Server 超时/重连 | ❌ | 未建模(有默认值) |
|
||||
|
||||
### 2.4 负载均衡与健康检查
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 多个 local 提供同一组(group/loadBalancer) | ❌ | 未建模 group/groupKey |
|
||||
| health check(tcp/http,失败剔除) | ❌ | 未建模 HealthCheckConfig |
|
||||
|
||||
### 2.5 域名与路由(http/https)
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| customDomains 多域名 | 🟡 | 可输出,但画布 local 无域名字段(写死 name.local) |
|
||||
| subdomain | 🟡 | render 支持生成,但依赖服务器 subdomain_host(演示环境未启用) |
|
||||
| locations(路径路由) | ❌ | 未建模 |
|
||||
| httpHeader 改写 / hostHeaderRewrite | ❌ | 未建模 |
|
||||
| basicAuth(站点访问认证) | ❌ | 未建模 |
|
||||
|
||||
### 2.6 插件(plugin)与虚拟 IP
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| client plugin(http2https/static/server 等) | ❌ | 未建模
|
||||
| vmnet(虚拟 IP) | ❌ | 未建模 |
|
||||
|
||||
### 2.7 其它
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 元数据 metadatas / annotations | ❌ | Remote 有 URL 但无 metadatas |
|
||||
| start (enabled 控制) | ✅ | Remote 有 Enabled |
|
||||
| 全局配置(byteLimit 等) | ❌ | 未建模 |
|
||||
|
||||
## 三、具体缺口细节
|
||||
|
||||
1. **Local/Remote 模型过简**:Local 只有 name/ip/port/protocol;缺 encryption/compression/bandwidth/plugin/metadatas/域名/路径/健康检查等。Remote 缺 protocol(kcp/quic/ws)、tls、oidc、pool 等。
|
||||
2. **渲染器只输出 6 个字段**:`brpcProxy` 仅 name/type/localIP/localPort/remotePort/customDomains/subdomain。**transport 段只有空 protocol**,frpc 其它传输参数全部缺失。
|
||||
3. **画布节点编辑只暴露 ip/port/协议**:连已有的 token/url 也只在部分入口可编辑,更别说高级字段。
|
||||
4. **udp 渲染无 UDPPacketSize 等**、**https 无证书流程**。
|
||||
|
||||
## 四、用户实际会遇到的典型场景覆盖
|
||||
|
||||
| 场景 | 现状 |
|
||||
|---|---|
|
||||
| 内网 Web 服务对外(http+tcp) | ✅ 覆盖 |
|
||||
| 普通 TCP 服务(数据库/SSH/自建协议) | ✅ 覆盖 |
|
||||
| 加密内网通信(stcp/sudp) | ❌ 若用户需要点对点加密穿透则无法完成 |
|
||||
| 多域名多路径挂多个站点到一台 frps | 🟡 部分(域名选择没做) |
|
||||
| 限速 / 压缩 / 加密传输 | ❌ |
|
||||
| 负载均衡 / 健康检查 | ❌ |
|
||||
| kcp/quic 弱网优化 | ❌ |
|
||||
|
||||
## 五、优先级建议
|
||||
|
||||
| 优先级 | 项 | 理由 |
|
||||
|---|---|---|
|
||||
| P0 | 画布冲突检查(本次已实现) | 防止重复 remotePort / 域名重复导致配置直接失败 |
|
||||
| P0 | Local 增加「连接参数」高级区:encryption / compression / bandwidthLimit / poolCount / metadatas | 高频项,改动小(渲染器 + 表单) |
|
||||
| P1 | Remote 增加 transport 选择:protocol(tcp/quic/kcp/websocket) 、tls.enable、poolCount | 让 kcp/quic/web 场景可用 |
|
||||
| P1 | local http/https 增加域名字段(customDomains/subdomain 可编辑)+ locations | 覆盖多站点部署 |
|
||||
| P2 | 健康检查 / 负载均衡(group) | 中频 |
|
||||
| P2 | 插件 stub / 虚拟 IP | 低频,且 UI 复杂 |
|
||||
| P3 | OIDC / TLS 证书 / 高级插件 | 低频,可延后 |
|
||||
|
||||
## 六、结论
|
||||
|
||||
webui4frpc 已具备**生产可用的核心**(tcp/http 多节点多对多转发 + 状态页 + worker 管理),且面向「避免运维手写配置」的目标价值明确。但要覆盖 frpc 完整能力需分阶段补齐:**先做 P0(冲突检查已做)+ P0 高级传输参数建模**,再逐级推进,避免一上来堆字段导致画布复杂化。建议 UI 采用「折叠高级区」或「按类型动态显示字段」,保持画布简洁。
|
||||
93
README.md
Normal file
93
README.md
Normal file
@ -0,0 +1,93 @@
|
||||
# webui4frpc
|
||||
|
||||
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。
|
||||
|
||||
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
|
||||
|
||||
## 特性
|
||||
|
||||
- **Scratch 风格画布**:本地转发项(local)与远程服务器(remote)可视化连线,一个 local 可连多个 remote,一个 remote 可连多个 local
|
||||
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
|
||||
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
|
||||
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
|
||||
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc,或手动指定二进制路径
|
||||
- **token 可视化**:remote 支持 token/URL 等字段
|
||||
|
||||
## 构建
|
||||
|
||||
### 后端(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
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```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 认证 |
|
||||
| `-workdir` | `./webui4frpc` | 数据目录(db/配置/日志/bin) |
|
||||
| `-bin` | 空 | 初始 frpc 路径(可选) |
|
||||
|
||||
## 数据目录
|
||||
|
||||
```
|
||||
<workdir>
|
||||
├── manager.db # SQLite 状态(locals/remotes/links/settings)
|
||||
├── configs/<name>.json # 每台 remote 渲染的 frpc 配置
|
||||
├── logs/<name>.log # worker 日志(按大小轮转)
|
||||
└── bin/frpc-<v> # 一键安装的 frpc
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
webui4frpc(单二进制,无 frp 依赖)
|
||||
├── cmds/webui4frpc 入口:HTTP 服务 + 生命周期
|
||||
├── internal/store SQLite 持久化
|
||||
├── internal/canvas 画布模型(local/remote/link)
|
||||
├── internal/render 渲染 frpc JSON 配置
|
||||
├── internal/process worker 进程管理(spawn/stop/restart/自愈)
|
||||
├── internal/httpapi REST API + 静态资源
|
||||
└── internal/install 一键下载官方 frpc
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/manager/status` | 总览状态 |
|
||||
| GET/PUT | `/api/manager/canvas` | 读写画布 |
|
||||
| GET/PUT | `/api/manager/settings` | 读写运行策略 |
|
||||
| POST | `/api/manager/binary/install` | 一键安装 frpc |
|
||||
| POST | `/api/manager/profiles/{name}/start\|stop\|restart` | worker 启停 |
|
||||
| GET | `/api/manager/profiles/{name}/config\|logs` | 查看配置/日志 |
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
# 前端
|
||||
cd web && npm run type-check
|
||||
```
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@ -0,0 +1,17 @@
|
||||
module webui4frpc
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.55.0
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
51
go.sum
Normal file
51
go.sum
Normal file
@ -0,0 +1,51 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/httpapi/dist/index.html
vendored
Normal file
13
internal/httpapi/dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
283
internal/httpapi/handlers.go
Normal file
283
internal/httpapi/handlers.go
Normal file
@ -0,0 +1,283 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// canvasData is the full drawing canvas exchanged with the frontend.
|
||||
type canvasData struct {
|
||||
Locals []store.Local `json:"locals"`
|
||||
Remotes []store.Remote `json:"remotes"`
|
||||
Links []store.Link `json:"links"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) {
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
links, _ := h.Store.ListLinks()
|
||||
writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleCanvasGet(w, r)
|
||||
case http.MethodPut:
|
||||
h.saveCanvas(w, r)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
var canvas canvasData
|
||||
if err := json.NewDecoder(r.Body).Decode(&canvas); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s := h.Store
|
||||
|
||||
// Upsert locals.
|
||||
for _, l := range canvas.Locals {
|
||||
if err := s.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete locals not present.
|
||||
if existing, err := s.ListLocals(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, l := range canvas.Locals {
|
||||
keep[l.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteLocal(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert remotes.
|
||||
for _, rem := range canvas.Remotes {
|
||||
if err := s.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing, err := s.ListRemotes(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, rem := range canvas.Remotes {
|
||||
keep[rem.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteRemote(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace links wholesale.
|
||||
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Restart affected running workers so changes take effect immediately.
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
settings, _ := h.Store.Settings()
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
var st store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpdateSettings(st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.handleSettingsGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Binary ----
|
||||
|
||||
func (h *Handler) handleBinaryStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := map[string]any{"binaryPath": ""}
|
||||
if h.BinaryPath != nil {
|
||||
resp["binaryPath"] = h.BinaryPath()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBinaryInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if h.InstallBinary == nil {
|
||||
http.Error(w, "install not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path, version, err := h.InstallBinary(req.Version)
|
||||
if err != nil {
|
||||
http.Error(w, "install failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "version": version})
|
||||
}
|
||||
|
||||
// ---- Single remote upsert/delete (used by the status page) ----
|
||||
|
||||
func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var rem store.Remote
|
||||
if err := json.NewDecoder(r.Body).Decode(&rem); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rem.Name == "" || rem.IP == "" || rem.Port <= 0 || rem.Port > 65535 {
|
||||
http.Error(w, "name/ip/port required, port in [1,65535]", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Start the worker if enabled.
|
||||
if rem.Enabled {
|
||||
_ = h.Process.Start(rem.Name)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rem)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/remotes/")
|
||||
if name == "" {
|
||||
http.Error(w, "remote name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = h.Process.Stop(name)
|
||||
if err := h.Store.DeleteRemote(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---- Profile lifecycle ----
|
||||
|
||||
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Path: /api/manager/profiles/{name}/{action?}
|
||||
rel := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.Error(w, "profile name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "":
|
||||
// GET profile status
|
||||
st, has := h.Process.Status(name)
|
||||
forwards, _ := h.Store.LinksForRemote(name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
|
||||
})
|
||||
case "start", "stop", "restart":
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var err error
|
||||
if action == "start" {
|
||||
err = h.Process.Start(name)
|
||||
} else if action == "stop" {
|
||||
err = h.Process.Stop(name)
|
||||
} else {
|
||||
err = h.Process.Restart(name)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
case "config":
|
||||
data, err := os.ReadFile(h.Process.ConfigPath(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
case "logs":
|
||||
path := h.Process.LogPath(name)
|
||||
data, err := tailFile(path, 64*1024)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(data))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
|
||||
|
||||
// tailFile returns the last maxBytes bytes of a file.
|
||||
func tailFile(path string, maxBytes int64) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
size := min(info.Size(), maxBytes)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, size)
|
||||
if _, err := f.ReadAt(buf, info.Size()-size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
213
internal/httpapi/server.go
Normal file
213
internal/httpapi/server.go
Normal file
@ -0,0 +1,213 @@
|
||||
// Package httpapi serves the web UI and REST API.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Handler bundles dependencies for the HTTP API.
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
Process *process.Manager
|
||||
WorkDir string
|
||||
BinDir string
|
||||
User string
|
||||
Password string
|
||||
|
||||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||||
// avoid an import cycle with the install package.
|
||||
InstallBinary func(version string) (path, ver string, err error)
|
||||
// BinaryPath resolves the current worker binary.
|
||||
BinaryPath func() string
|
||||
// RunInstall is a hook to trigger canary tasks after canvas save.
|
||||
SyncWorkers func()
|
||||
}
|
||||
|
||||
const (
|
||||
healthzPath = "/healthz"
|
||||
apiPrefix = "/api/manager"
|
||||
)
|
||||
|
||||
// NewServeMux builds the full HTTP handler.
|
||||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != h.User || p != h.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// API routes (basic auth).
|
||||
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
||||
mux.HandleFunc(apiPrefix+"/settings", auth(h.handleSettingsGet))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
||||
|
||||
// Profile lifecycle routes.
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
||||
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
||||
|
||||
// Static assets (also basic auth) under /.
|
||||
mux.HandleFunc("/", h.handleStatic)
|
||||
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
// handleStatic serves the embedded web build. Paths map to dist files; / and
|
||||
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
|
||||
// routing, so returning index.html for "/" is enough — a redirect here would
|
||||
// loop (the hash fragment never reaches the server).
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
http.Error(w, "assets unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
data, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
// SPA fallback: any non-file path serves index.html.
|
||||
data, err = fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name = "index.html"
|
||||
}
|
||||
if ct := contentTypeFor(name); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func contentTypeFor(name string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
case strings.HasSuffix(name, ".js"):
|
||||
return "application/javascript"
|
||||
case strings.HasSuffix(name, ".css"):
|
||||
return "text/css"
|
||||
case strings.HasSuffix(name, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(name, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(name, ".ico"):
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
settings, _ := h.Store.Settings()
|
||||
|
||||
type profileStatus struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status process.Status `json:"process"`
|
||||
HasProc bool `json:"hasProcess"`
|
||||
Forwards []store.Forward `json:"forwards"`
|
||||
}
|
||||
|
||||
profiles := make([]profileStatus, 0, len(remotes))
|
||||
binary := ""
|
||||
if h.BinaryPath != nil {
|
||||
binary = h.BinaryPath()
|
||||
}
|
||||
for _, rv := range remotes {
|
||||
st, has := h.Process.Status(rv.Name)
|
||||
fwd, _ := h.Store.LinksForRemote(rv.Name)
|
||||
profiles = append(profiles, profileStatus{
|
||||
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
|
||||
})
|
||||
}
|
||||
|
||||
// Local status: each local plus its forwarding targets and whether each
|
||||
// target's worker is healthy (running).
|
||||
type localTargetStatus struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
WorkerState string `json:"workerState"`
|
||||
}
|
||||
type localStatus struct {
|
||||
Local store.Local `json:"local"`
|
||||
Targets []localTargetStatus `json:"targets"`
|
||||
}
|
||||
|
||||
localStatuses := make([]localStatus, 0, len(locals))
|
||||
for _, l := range locals {
|
||||
targets, err := h.Store.LinksForLocal(l.Name)
|
||||
if err != nil {
|
||||
targets = nil
|
||||
}
|
||||
ts := make([]localTargetStatus, 0, len(targets))
|
||||
for _, tg := range targets {
|
||||
st, _ := h.Process.Status(tg.Remote)
|
||||
ts = append(ts, localTargetStatus{
|
||||
Remote: tg.Remote,
|
||||
RemotePort: tg.RemotePort,
|
||||
WorkerState: st.State,
|
||||
})
|
||||
}
|
||||
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"version": "0.1.0",
|
||||
"workDir": h.WorkDir,
|
||||
"settings": settings,
|
||||
"services": locals,
|
||||
"remotes": remotes,
|
||||
"binaryPath": binary,
|
||||
"profiles": profiles,
|
||||
"localStatus": localStatuses,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = fmtJSONEncode(w, v)
|
||||
}
|
||||
|
||||
func fmtJSONEncode(w http.ResponseWriter, v any) error {
|
||||
return json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
99
internal/httpapi/server_test.go
Normal file
99
internal/httpapi/server_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *httptest.Server) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := process.NewManager(process.Options{
|
||||
ConfigsDir: filepath.Join(dir, "configs"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
BinaryPath: func() string { return "" },
|
||||
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
||||
AutoRestart: func(string) bool { return false },
|
||||
RestartInterval: func() int { return 5 },
|
||||
})
|
||||
|
||||
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw"}
|
||||
mux, err := NewServeMux(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return h, ts
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
resp, err := http.Get(ts.URL + "/api/manager/status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasRoundTrip(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
client := ts.Client()
|
||||
|
||||
body := `{
|
||||
"locals": [{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
|
||||
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||
"links": [{"local":"web","remote":"srv-a","remotePort":8080}]
|
||||
}`
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||
req.SetBasicAuth("admin", "pw")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("save canvas status = %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got canvasData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Locals) != 1 || got.Locals[0].Name != "web" {
|
||||
t.Fatalf("locals = %+v", got.Locals)
|
||||
}
|
||||
if len(got.Links) != 1 || got.Links[0].RemotePort != 8080 {
|
||||
t.Fatalf("links = %+v", got.Links)
|
||||
}
|
||||
|
||||
// GET again to confirm persistence via store.
|
||||
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/canvas", nil)
|
||||
req2.SetBasicAuth("admin", "pw")
|
||||
resp2, err2 := client.Do(req2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
var got2 canvasData
|
||||
_ = json.NewDecoder(resp2.Body).Decode(&got2)
|
||||
if len(got2.Remotes) != 1 || got2.Remotes[0].Name != "srv-a" {
|
||||
t.Fatalf("remotes after reload = %+v", got2.Remotes)
|
||||
}
|
||||
}
|
||||
203
internal/install/install.go
Normal file
203
internal/install/install.go
Normal file
@ -0,0 +1,203 @@
|
||||
// Package install downloads official frpc binaries from GitHub releases.
|
||||
package install
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
releasesBase = "https://github.com/fatedier/frp/releases/download"
|
||||
// maxArchiveFileSize caps a single extracted file against decompression bombs.
|
||||
maxArchiveFileSize = 256 << 20 // 256 MiB
|
||||
)
|
||||
|
||||
// latestAPI is a var so tests can point it at a local server.
|
||||
var latestAPI = "https://api.github.com/repos/fatedier/frp/releases/latest"
|
||||
|
||||
// Install downloads the frpc release for the requested version (empty = latest)
|
||||
// into binDir, verifies it runs, and returns the path and installed version.
|
||||
func Install(ctx context.Context, binDir, version string) (path, installedVersion string, err error) {
|
||||
if version == "" {
|
||||
version, err = latestVersion()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve latest version: %w", err)
|
||||
}
|
||||
}
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
|
||||
platform := platformName()
|
||||
pkgName := fmt.Sprintf("frp_%s_%s.tar.gz", version, platform)
|
||||
url := fmt.Sprintf("%s/v%s/%s", releasesBase, version, pkgName)
|
||||
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tmpArchive := filepath.Join(binDir, pkgName+".download")
|
||||
if err := download(ctx, url, tmpArchive); err != nil {
|
||||
return "", "", fmt.Errorf("download %s: %w", url, err)
|
||||
}
|
||||
defer os.Remove(tmpArchive)
|
||||
|
||||
extractDir := filepath.Join(binDir, "frpc-"+version)
|
||||
if err := os.RemoveAll(extractDir); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := extractTarGz(tmpArchive, extractDir); err != nil {
|
||||
return "", "", fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
// Locate the frpc executable in the package's top-level directory.
|
||||
src, err := findFrpc(extractDir)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", err
|
||||
}
|
||||
dst := filepath.Join(extractDir, "frpc")
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", err
|
||||
}
|
||||
_ = os.Chmod(dst, 0o755)
|
||||
|
||||
if out, err := exec.Command(dst, "--version").Output(); err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", fmt.Errorf("downloaded binary not runnable: %w", err)
|
||||
} else if ver := strings.TrimSpace(string(out)); ver != "" {
|
||||
version = ver
|
||||
}
|
||||
return dst, version, nil
|
||||
}
|
||||
|
||||
// latestVersion returns the newest release tag from the GitHub API.
|
||||
func latestVersion() (string, error) {
|
||||
c := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := c.Get(latestAPI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rel.TagName, nil
|
||||
}
|
||||
|
||||
func download(ctx context.Context, url, path string) error {
|
||||
c := &http.Client{Timeout: 90 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func platformName() string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "x86_64" {
|
||||
arch = "amd64"
|
||||
}
|
||||
return runtime.GOOS + "_" + arch
|
||||
}
|
||||
|
||||
func extractTarGz(archive, dest string) error {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.Clean(hdr.Name)
|
||||
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
|
||||
continue // path traversal guard
|
||||
}
|
||||
target := filepath.Join(dest, name)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, io.LimitReader(tr, maxArchiveFileSize)); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
_ = out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findFrpc(dir string) (string, error) {
|
||||
var found string
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && d.Name() == "frpc" {
|
||||
found = path
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("no frpc file under %s", dir)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
33
internal/install/install_test.go
Normal file
33
internal/install/install_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLatestVersion(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v0.71.0"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
old := latestAPI
|
||||
latestAPI = ts.URL
|
||||
defer func() { latestAPI = old }()
|
||||
|
||||
v, err := latestVersion()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != "v0.71.0" {
|
||||
t.Fatalf("version = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformName(t *testing.T) {
|
||||
p := platformName()
|
||||
if p == "" || len(p) < 5 {
|
||||
t.Fatalf("platform = %q", p)
|
||||
}
|
||||
}
|
||||
293
internal/process/process.go
Normal file
293
internal/process/process.go
Normal file
@ -0,0 +1,293 @@
|
||||
// Package process supervises worker frpc processes, one per remote.
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status describes the current state of a worker process.
|
||||
type Status struct {
|
||||
State string `json:"state"` // stopped | starting | running | restarting | crashed
|
||||
Pid int `json:"pid,omitempty"`
|
||||
StartTime int64 `json:"startTime,omitempty"`
|
||||
RestartCount int `json:"restartCount"`
|
||||
ExitCode int `json:"exitCode,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
stopGraceTimeout = 10 * time.Second
|
||||
maxRestartDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
// Options configures a Manager.
|
||||
type Options struct {
|
||||
// ConfigsDir is where rendered JSON configs are written.
|
||||
ConfigsDir string
|
||||
// LogsDir is where worker output is captured.
|
||||
LogsDir string
|
||||
// BinaryPath returns the frpc binary to spawn (resolved dynamically).
|
||||
BinaryPath func() string
|
||||
// Render returns the config bytes for a remote.
|
||||
Render func(remoteName string) ([]byte, error)
|
||||
// AutoRestart returns whether to auto-restart a remote's worker.
|
||||
AutoRestart func(remoteName string) bool
|
||||
// RestartInterval returns the base restart interval in seconds.
|
||||
RestartInterval func() int
|
||||
}
|
||||
|
||||
type worker struct {
|
||||
name string
|
||||
proc *exec.Cmd
|
||||
logFile *rotatingFile
|
||||
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
status Status
|
||||
}
|
||||
|
||||
// Manager supervises all workers.
|
||||
type Manager struct {
|
||||
opts Options
|
||||
mu sync.Mutex
|
||||
workers map[string]*worker
|
||||
}
|
||||
|
||||
// NewManager builds a worker supervisor.
|
||||
func NewManager(opts Options) *Manager {
|
||||
return &Manager{opts: opts, workers: make(map[string]*worker)}
|
||||
}
|
||||
|
||||
func (m *Manager) getStatus(w *worker) Status {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (m *Manager) setState(w *worker, s Status) {
|
||||
w.mu.Lock()
|
||||
w.status = s
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start renders and spawns a remote's worker. Idempotent if already running.
|
||||
func (m *Manager) Start(name string) error {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w != nil && statusRunning(w) {
|
||||
return nil
|
||||
}
|
||||
|
||||
w = &worker{
|
||||
name: name,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.workers[name] = w
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.spawn(w); err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.workers, name)
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusRunning(w *worker) bool {
|
||||
select {
|
||||
case <-w.doneCh:
|
||||
return false
|
||||
default:
|
||||
return w.proc != nil && w.proc.Process != nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) spawn(w *worker) error {
|
||||
data, err := m.opts.Render(w.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render config for %q: %w", w.name, err)
|
||||
}
|
||||
cfgPath := filepath.Join(m.opts.ConfigsDir, w.name+".json")
|
||||
if err := os.MkdirAll(m.opts.ConfigsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logWriter, err := newRotatingFile(filepath.Join(m.opts.LogsDir, w.name+".log"), 10*1024*1024)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(m.opts.BinaryPath(), "-c", cfgPath)
|
||||
cmd.Stdout = logWriter
|
||||
cmd.Stderr = logWriter
|
||||
setSysProcAttr(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logWriter.Close()
|
||||
return fmt.Errorf("start worker %q: %w", w.name, err)
|
||||
}
|
||||
|
||||
restartCount := m.getStatus(w).RestartCount
|
||||
w.proc = cmd
|
||||
w.logFile = logWriter
|
||||
m.setState(w, Status{State: "running", Pid: cmd.Process.Pid, StartTime: time.Now().Unix(), RestartCount: restartCount})
|
||||
go m.supervise(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) supervise(w *worker) {
|
||||
defer close(w.doneCh)
|
||||
for {
|
||||
err := w.proc.Wait()
|
||||
exitCode := 0
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if w.logFile != nil {
|
||||
_ = w.logFile.Close()
|
||||
w.logFile = nil
|
||||
}
|
||||
w.proc = nil
|
||||
stopRequested := false
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
stopRequested = true
|
||||
default:
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.setState(w, Status{State: "stopped", ExitCode: exitCode, RestartCount: m.getStatus(w).RestartCount})
|
||||
if stopRequested {
|
||||
return
|
||||
}
|
||||
if !m.opts.AutoRestart(w.name) {
|
||||
return
|
||||
}
|
||||
|
||||
rc := m.getStatus(w).RestartCount + 1
|
||||
m.setState(w, Status{State: "restarting", RestartCount: rc})
|
||||
select {
|
||||
case <-time.After(m.restartDelay(rc)):
|
||||
case <-w.stopCh:
|
||||
m.setState(w, Status{State: "stopped", RestartCount: rc})
|
||||
return
|
||||
}
|
||||
if err := m.spawn(w); err != nil {
|
||||
m.setState(w, Status{State: "crashed", Err: err.Error(), RestartCount: rc})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) restartDelay(count int) time.Duration {
|
||||
base := 5 * time.Second
|
||||
if m.opts.RestartInterval != nil {
|
||||
if s := m.opts.RestartInterval(); s > 0 {
|
||||
base = time.Duration(s) * time.Second
|
||||
}
|
||||
}
|
||||
d := base
|
||||
for i := 1; i < count; i++ {
|
||||
d *= 2
|
||||
if d >= maxRestartDelay {
|
||||
return maxRestartDelay
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Stop gracefully stops a worker and waits for exit.
|
||||
func (m *Manager) Stop(name string) error {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
w.stopOnce.Do(func() { close(w.stopCh) })
|
||||
|
||||
m.mu.Lock()
|
||||
proc := w.proc
|
||||
m.mu.Unlock()
|
||||
if proc != nil {
|
||||
signalGroup(proc, syscall.SIGTERM)
|
||||
}
|
||||
select {
|
||||
case <-w.doneCh:
|
||||
case <-time.After(stopGraceTimeout):
|
||||
m.mu.Lock()
|
||||
proc := w.proc
|
||||
m.mu.Unlock()
|
||||
if proc != nil {
|
||||
signalGroup(proc, syscall.SIGKILL)
|
||||
}
|
||||
<-w.doneCh
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.workers, name)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops and starts a worker.
|
||||
func (m *Manager) Restart(name string) error {
|
||||
if err := m.Stop(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start(name)
|
||||
}
|
||||
|
||||
// Status returns the current status of a remote's worker.
|
||||
func (m *Manager) Status(name string) (Status, bool) {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w == nil {
|
||||
return Status{State: "stopped"}, false
|
||||
}
|
||||
return m.getStatus(w), true
|
||||
}
|
||||
|
||||
// StopAll stops all workers (used on manager shutdown).
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
names := make([]string, 0, len(m.workers))
|
||||
for n := range m.workers {
|
||||
names = append(names, n)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range names {
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
_ = m.Stop(name)
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// LogPath returns the log file path for a remote.
|
||||
func (m *Manager) LogPath(name string) string {
|
||||
return filepath.Join(m.opts.LogsDir, name+".log")
|
||||
}
|
||||
|
||||
// ConfigPath returns the rendered config path for a remote.
|
||||
func (m *Manager) ConfigPath(name string) string {
|
||||
return filepath.Join(m.opts.ConfigsDir, name+".json")
|
||||
}
|
||||
59
internal/process/process_test.go
Normal file
59
internal/process/process_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "fake-frpc")
|
||||
// A fake frpc that sleeps (simulating a healthy worker).
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := Options{
|
||||
ConfigsDir: filepath.Join(dir, "configs"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
BinaryPath: func() string { return script },
|
||||
Render: func(name string) ([]byte, error) {
|
||||
return []byte(`{"name":"` + name + `"}`), nil
|
||||
},
|
||||
AutoRestart: func(string) bool { return false },
|
||||
RestartInterval: func() int { return 1 },
|
||||
}
|
||||
return NewManager(opts)
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
pm := newTestManager(t)
|
||||
if err := pm.Start("srv-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, has := pm.Status("srv-a")
|
||||
if !has || st.State != "running" || st.Pid <= 0 {
|
||||
t.Fatalf("status = %+v has=%v", st, has)
|
||||
}
|
||||
if err := pm.Stop("srv-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, has = pm.Status("srv-a")
|
||||
if has {
|
||||
t.Fatalf("after stop has=%v", has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWritten(t *testing.T) {
|
||||
pm := newTestManager(t)
|
||||
_ = pm.Start("srv-b")
|
||||
defer pm.Stop("srv-b")
|
||||
data, err := os.ReadFile(pm.ConfigPath("srv-b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != `{"name":"srv-b"}` {
|
||||
t.Fatalf("config = %s", data)
|
||||
}
|
||||
}
|
||||
21
internal/process/process_unix.go
Normal file
21
internal/process/process_unix.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr runs the worker in its own process group.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// signalGroup delivers sig to the worker and its whole process group.
|
||||
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, sig)
|
||||
}
|
||||
21
internal/process/process_windows.go
Normal file
21
internal/process/process_windows.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr runs the worker in a new process group on Windows.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
// signalGroup signals only the direct process on Windows.
|
||||
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Signal(sig)
|
||||
}
|
||||
73
internal/process/rotating.go
Normal file
73
internal/process/rotating.go
Normal file
@ -0,0 +1,73 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// rotatingFile appends to a log file and rotates it once it exceeds maxSize.
|
||||
type rotatingFile struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxSize int64
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRotatingFile(path string, maxSize int64) (*rotatingFile, error) {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 10 * 1024 * 1024
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &rotatingFile{path: path, maxSize: maxSize, f: f, size: info.Size()}, nil
|
||||
}
|
||||
|
||||
func (r *rotatingFile) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.f == nil {
|
||||
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.f = f
|
||||
}
|
||||
if r.size > 0 && r.size+int64(len(p)) > r.maxSize {
|
||||
_ = r.f.Close()
|
||||
_ = os.Rename(r.path, r.path+".1")
|
||||
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
r.f = nil
|
||||
return 0, err
|
||||
}
|
||||
r.f = f
|
||||
r.size = 0
|
||||
}
|
||||
n, err := r.f.Write(p)
|
||||
r.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *rotatingFile) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.f != nil {
|
||||
err := r.f.Close()
|
||||
r.f = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
104
internal/render/render.go
Normal file
104
internal/render/render.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Package render generates frpc worker configuration (JSON) from canvas data.
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// Proxy is a single proxy to render inside a worker config. It carries enough
|
||||
// info to build any supported frpc proxy type.
|
||||
type Proxy struct {
|
||||
// Name is the display name (the local service name).
|
||||
Name string
|
||||
// Type is the proxy type: tcp | udp | http | https.
|
||||
Type string
|
||||
// LocalIP and LocalPort are the backend address.
|
||||
LocalIP string
|
||||
LocalPort int
|
||||
// RemotePort is the exposed port on the frps server (tcp/udp only).
|
||||
RemotePort int
|
||||
// SubDomain is used by http/https proxies when set.
|
||||
SubDomain string
|
||||
// CustomDomains is used by http/https proxies when set.
|
||||
CustomDomains []string
|
||||
}
|
||||
|
||||
// frpcAuth mirrors frpc's auth section.
|
||||
type frpcAuth struct {
|
||||
Method string `json:"method"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// frpcTransport is the transport section used by frpc.
|
||||
type frpcTransport struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// frpcProxy is the serialized proxy object. Different types use different
|
||||
// fields; empty ones are omitted so the JSON is accepted by frpc.
|
||||
type frpcProxy struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"localIP,omitempty"`
|
||||
LocalPort int `json:"localPort"`
|
||||
RemotePort int `json:"remotePort,omitempty"`
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
SubDomain string `json:"subdomain,omitempty"`
|
||||
}
|
||||
|
||||
// Config is a complete frpc configuration document.
|
||||
type Config struct {
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerPort int `json:"serverPort"`
|
||||
Auth frpcAuth `json:"auth"`
|
||||
Transport frpcTransport `json:"transport,omitempty"`
|
||||
LoginFailExit bool `json:"loginFailExit"`
|
||||
Proxies []frpcProxy `json:"proxies"`
|
||||
}
|
||||
|
||||
// Render builds the worker config JSON for a remote server from its setting
|
||||
// plus the list of proxies to forward.
|
||||
func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
|
||||
cfg := Config{
|
||||
ServerAddr: remote.IP,
|
||||
ServerPort: remote.Port,
|
||||
Auth: frpcAuth{Method: "token", Token: remote.Token},
|
||||
LoginFailExit: false,
|
||||
Proxies: make([]frpcProxy, 0, len(proxies)),
|
||||
}
|
||||
|
||||
nameCounts := make(map[string]int)
|
||||
for _, p := range proxies {
|
||||
frpcP := frpcProxy{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
LocalIP: p.LocalIP,
|
||||
LocalPort: p.LocalPort,
|
||||
SubDomain: p.SubDomain,
|
||||
}
|
||||
// http/https proxies use customDomains instead of remotePort. The
|
||||
// default domain is <name>.local; frps matches it by Host header.
|
||||
if p.Type == "http" || p.Type == "https" {
|
||||
frpcP.RemotePort = 0
|
||||
if frpcP.SubDomain == "" && len(frpcP.CustomDomains) == 0 {
|
||||
frpcP.CustomDomains = []string{p.Name + ".local"}
|
||||
}
|
||||
} else {
|
||||
frpcP.RemotePort = p.RemotePort
|
||||
}
|
||||
if len(p.CustomDomains) > 0 {
|
||||
frpcP.CustomDomains = p.CustomDomains
|
||||
}
|
||||
// Duplicate service name -> suffix with the remote port so frpc accepts
|
||||
// multiple proxies for the same local service.
|
||||
nameCounts[frpcP.Name]++
|
||||
if nameCounts[frpcP.Name] > 1 {
|
||||
frpcP.Name = fmt.Sprintf("%s-%d", frpcP.Name, frpcP.RemotePort)
|
||||
}
|
||||
cfg.Proxies = append(cfg.Proxies, frpcP)
|
||||
}
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
48
internal/render/render_test.go
Normal file
48
internal/render/render_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
func TestRenderTCPProxy(t *testing.T) {
|
||||
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
|
||||
data, err := Render(remote, []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ServerAddr != "1.2.3.4" || cfg.Auth.Token != "secret" {
|
||||
t.Fatalf("cfg = %+v", cfg)
|
||||
}
|
||||
if len(cfg.Proxies) != 1 || cfg.Proxies[0].RemotePort != 8080 {
|
||||
t.Fatalf("proxies = %+v", cfg.Proxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
|
||||
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
|
||||
proxy := []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8081},
|
||||
}
|
||||
data, err := Render(remote, proxy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if len(cfg.Proxies) != 2 {
|
||||
t.Fatalf("proxies = %+v", cfg.Proxies)
|
||||
}
|
||||
if cfg.Proxies[1].Name != "web-8081" {
|
||||
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name)
|
||||
}
|
||||
}
|
||||
336
internal/store/store.go
Normal file
336
internal/store/store.go
Normal file
@ -0,0 +1,336 @@
|
||||
// Package store implements the persistence layer for webui-frpc.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Local is a local forward service node on the canvas.
|
||||
type Local struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"` // tcp | udp | http | https
|
||||
}
|
||||
|
||||
// Remote is a remote server node on the canvas.
|
||||
type Remote struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"` // port to connect to frps
|
||||
Token string `json:"token,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Link connects one local to one remote.
|
||||
type Link struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
OffsetX int `json:"offsetX,omitempty"`
|
||||
OffsetY int `json:"offsetY,omitempty"`
|
||||
}
|
||||
|
||||
// Settings holds runtime options.
|
||||
type Settings struct {
|
||||
AutoStartProfiles bool `json:"autoStartProfiles"`
|
||||
RestartOnExit bool `json:"restartOnExit"`
|
||||
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
|
||||
BinaryPath string `json:"binaryPath,omitempty"`
|
||||
}
|
||||
|
||||
// Forward is a rendered link row attached to a remote.
|
||||
type Forward struct {
|
||||
Service string `json:"service"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
LocalPort int `json:"localPort,omitempty"`
|
||||
OffsetX int `json:"offsetX,omitempty"`
|
||||
OffsetY int `json:"offsetY,omitempty"`
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS locals (
|
||||
name TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS remotes (
|
||||
name TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
local TEXT NOT NULL REFERENCES locals(name) ON DELETE CASCADE,
|
||||
remote TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE,
|
||||
remote_port INTEGER NOT NULL DEFAULT 0,
|
||||
offset_x INTEGER NOT NULL DEFAULT 0,
|
||||
offset_y INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_remote ON links(remote);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// Store is the SQLite persistence layer.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New opens the database at path, creating the schema if needed.
|
||||
func New(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create dir: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("enable fk: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("secure db: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// ---- Locals ----
|
||||
|
||||
func (s *Store) ListLocals() ([]Local, error) {
|
||||
rows, err := s.db.Query("SELECT name, ip, port, protocol FROM locals ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Local
|
||||
for rows.Next() {
|
||||
var l Local
|
||||
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLocal(name string) (Local, bool) {
|
||||
var l Local
|
||||
row := s.db.QueryRow("SELECT name, ip, port, protocol FROM locals WHERE name = ?", name)
|
||||
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
return Local{}, false
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertLocal(l Local) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO locals(name, ip, port, protocol) VALUES(?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol",
|
||||
l.Name, l.IP, l.Port, l.Protocol,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteLocal(name string) error {
|
||||
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Remotes ----
|
||||
|
||||
func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled FROM remotes ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Remote
|
||||
for rows.Next() {
|
||||
var r Remote
|
||||
var en int
|
||||
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetRemote(name string) (Remote, bool) {
|
||||
var r Remote
|
||||
var en int
|
||||
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled FROM remotes WHERE name = ?", name)
|
||||
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
return Remote{}, false
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
return r, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRemote(r Remote) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO remotes(name, ip, port, token, url, enabled) VALUES(?,?,?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled",
|
||||
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRemote(name string) error {
|
||||
_, err := s.db.Exec("DELETE FROM remotes WHERE name = ?", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Links ----
|
||||
|
||||
func (s *Store) ListLinks() ([]Link, error) {
|
||||
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y FROM links")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Link
|
||||
for rows.Next() {
|
||||
var l Link
|
||||
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LocalTarget describes one outgoing forward of a local service.
|
||||
type LocalTarget struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
}
|
||||
|
||||
// LinksForLocal returns the targets a local service forwards to.
|
||||
func (s *Store) LinksForLocal(local string) ([]LocalTarget, error) {
|
||||
links, err := s.ListLinks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []LocalTarget
|
||||
for _, l := range links {
|
||||
if l.Local != local {
|
||||
continue
|
||||
}
|
||||
out = append(out, LocalTarget{Remote: l.Remote, RemotePort: l.RemotePort})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LinksForRemote returns forwards of one remote with local port resolved.
|
||||
func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
|
||||
links, err := s.ListLinks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Forward
|
||||
for _, l := range links {
|
||||
if l.Remote != remote {
|
||||
continue
|
||||
}
|
||||
loc, ok := s.GetLocal(l.Local)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Forward{
|
||||
Service: l.Local,
|
||||
RemotePort: l.RemotePort,
|
||||
LocalPort: loc.Port,
|
||||
OffsetX: l.OffsetX,
|
||||
OffsetY: l.OffsetY,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReplaceLinks clears all links and inserts the given set in one transaction.
|
||||
func (s *Store) ReplaceLinks(links []Link) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec("DELETE FROM links"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range links {
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y) VALUES(?,?,?,?,?)",
|
||||
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
func (s *Store) Settings() (Settings, error) {
|
||||
var st Settings
|
||||
row := s.db.QueryRow("SELECT value FROM settings WHERE key = 'settings'")
|
||||
var raw string
|
||||
if err := row.Scan(&raw); err != nil {
|
||||
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &st); err != nil {
|
||||
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSettings(st Settings) error {
|
||||
raw, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
string(raw),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrInvalid = errors.New("invalid argument")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
)
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
60
internal/store/store_test.go
Normal file
60
internal/store/store_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalRemoteLinkRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.UpsertLocal(Local{Name: "web", IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertRemote(Remote{Name: "srv-a", IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ReplaceLinks([]Link{{Local: "web", Remote: "srv-a", RemotePort: 8080}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
locals, _ := st.ListLocals()
|
||||
if len(locals) != 1 || locals[0].Name != "web" {
|
||||
t.Fatalf("locals = %+v", locals)
|
||||
}
|
||||
remotes, _ := st.ListRemotes()
|
||||
if len(remotes) != 1 || remotes[0].Token != "tok" {
|
||||
t.Fatalf("remotes = %+v", remotes)
|
||||
}
|
||||
fwds, err := st.LinksForRemote("srv-a")
|
||||
if err != nil || len(fwds) != 1 {
|
||||
t.Fatalf("forwards = %+v err=%v", fwds, err)
|
||||
}
|
||||
if fwds[0].LocalPort != 8080 || fwds[0].RemotePort != 8080 {
|
||||
t.Fatalf("forward = %+v", fwds[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceLinksClearsOld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := New(filepath.Join(dir, "test.db"))
|
||||
defer st.Close()
|
||||
_ = st.UpsertLocal(Local{Name: "a", IP: "127.0.0.1", Port: 1, Protocol: "tcp"})
|
||||
_ = st.UpsertLocal(Local{Name: "b", IP: "127.0.0.1", Port: 2, Protocol: "tcp"})
|
||||
_ = st.UpsertRemote(Remote{Name: "r", IP: "1.2.3.4", Port: 7000, Enabled: true})
|
||||
if err := st.ReplaceLinks([]Link{{Local: "a", Remote: "r", RemotePort: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ReplaceLinks([]Link{{Local: "b", Remote: "r", RemotePort: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
links, _ := st.ListLinks()
|
||||
if len(links) != 1 || links[0].Local != "b" {
|
||||
t.Fatalf("links = %+v", links)
|
||||
}
|
||||
}
|
||||
68
plan.md
Normal file
68
plan.md
Normal file
@ -0,0 +1,68 @@
|
||||
# webui4frpc 开发计划
|
||||
|
||||
> webui4frpc —— 独立于 frp 源码的可视化 frpc 控制器(单二进制,零 frp 代码依赖,frpc 运行时一键从官方 Releases 下载或手动指定)。
|
||||
|
||||
## 目标
|
||||
|
||||
用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,避免运维反复手写 frpc 配置。
|
||||
|
||||
## 已完成(M0:骨架与核心)
|
||||
|
||||
- [x] 独立仓库:module `webui4frpc`,仅依赖 `modernc.org/sqlite`,零 frp 源码引用
|
||||
- [x] 目录结构:cmd / internal/{store,render,process,httpapi,install} + web(Vue3 + VueFlow + Element Plus)
|
||||
- [x] 画布模型:local / remote / link 多对多连线,节点可编辑、可添加
|
||||
- [x] 渲染器:tcp/udp/http/https → frpc JSON 配置(http 用 customDomains=`<name>.local`)
|
||||
- [x] worker 进程管理:spawn/stop/restart、日志轮转、崩溃自愈(指数退避)
|
||||
- [x] 一键安装 frpc(GitHub Releases)+ 手动指定路径
|
||||
- [x] REST API:status/canvas/settings/remotes/profiles/binary
|
||||
- [x] 三页 UI:状态(默认)/ 连接配置 / 设置
|
||||
- [x] 状态页:远程节点状态框 + 本地服务转发表(每 5s 轮询),节点可增删改启停
|
||||
- [x] 画布可用性检查:端口冲突 / http 域名唯一性,标红 + 弹窗提示,保存前拦截
|
||||
- [x] 演示环境:3 本地 × 3 远程多对多全连通(tcp + http 转发实测返回数据)
|
||||
- [x] 后端单元测试:store / render / process / httpapi / install
|
||||
|
||||
## 里程碑
|
||||
|
||||
### M1 高级传输参数(P0,建议优先)
|
||||
|
||||
目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。
|
||||
|
||||
- [ ] Local 增加高级字段:useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations(渲染器 + 编辑表单 + 折叠 UI)
|
||||
- [ ] Remote 增加 transport:protocol(tcp/quic/kcp/websocket)、tls.enable、poolCount
|
||||
- [ ] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
|
||||
- [ ] 高级字段随 canvas API 持久化(store 表扩展 + 迁移)
|
||||
|
||||
### M2 HTTP/HTTPS 完善
|
||||
|
||||
- [ ] local http/https 增加可编辑域名字段(customDomains / subdomain)
|
||||
- [ ] locations(路径路由)多配置
|
||||
- [ ] httpHeaderRewrite / hostHeaderRewrite / basicAuth(站点访问认证)
|
||||
- [ ] frps vhostHTTPPort 的显示与状态映射(状态页展示)
|
||||
|
||||
### M3 负载均衡与健康检查
|
||||
|
||||
- [ ] group / groupKey(多后端负载均衡)
|
||||
- [ ] health check:tcp / http,失败剔除与恢复
|
||||
- [ ] 状态页体现 LB 组与健康状态
|
||||
|
||||
### M4 更多代理类型
|
||||
|
||||
- [ ] tcpmux(HTTP/2 多路复用)
|
||||
- [ ] stcp / sudp(点对点加密穿透)
|
||||
- [ ] xtcp(P2P,需 kcp)
|
||||
- [ ] 按类型动态显示字段的表单
|
||||
|
||||
### M5 运维与发布
|
||||
|
||||
- [ ] git init + 首次 commit + .gitignore(排除 node_modules/dist)
|
||||
- [ ] 一键构建脚本(web→dist→embed→单二进制)
|
||||
- [ ] GitHub Actions:release 构建(linux/windows/mac)
|
||||
- [ ] 配置导出/导入(备份)
|
||||
- [ ] 日志查看页(worker 日志 tail UI)
|
||||
|
||||
## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md)
|
||||
|
||||
- 已支持:tcp / http(customDomains)、token 认证、多对多、worker 管理
|
||||
- 未支持:stcp/sudp/xtcp/tcpmux、OIDC/TLS 证书、加密/压缩/限速/连接池、LB/健康检查、http 路由高级、插件/虚拟 IP
|
||||
- 优先级:P0 高级传输参数 → P1 域名字段+transport → P2 LB/健康检查 → P3 低频项
|
||||
- UI 原则:高级字段折叠式 / 按类型动态显示,保画布简洁
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2887
web/package-lock.json
generated
Normal file
2887
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web/package.json
Normal file
27
web/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "webui4frpc-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"sass-embedded": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
95
web/src/App.vue
Normal file
95
web/src/App.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<!-- 简单 SPA 外壳:顶部切换 状态 / 连接配置 / 设置 -->
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">webui4frpc</span>
|
||||
<nav class="nav">
|
||||
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
|
||||
状态
|
||||
</button>
|
||||
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
|
||||
连接配置
|
||||
</button>
|
||||
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
|
||||
设置
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
<CanvasView v-if="view === 'canvas'" />
|
||||
<SettingsView v-else-if="view === 'settings'" />
|
||||
<StatusView v-else />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CanvasView from './views/CanvasView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import StatusView from './views/StatusView.vue'
|
||||
|
||||
const view = ref<'canvas' | 'settings' | 'status'>('status')
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
height: 52px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #303133;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
84
web/src/api.ts
Normal file
84
web/src/api.ts
Normal file
@ -0,0 +1,84 @@
|
||||
// HTTP client and API functions for webui4frpc.
|
||||
import type {
|
||||
BinaryStatus,
|
||||
CanvasData,
|
||||
InstallResult,
|
||||
Remote,
|
||||
Settings,
|
||||
StatusResp,
|
||||
} from './types'
|
||||
|
||||
class HTTPError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new HTTPError(response.status, `HTTP ${response.status}`)
|
||||
}
|
||||
const ct = response.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) {
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
return response.text() as unknown as Promise<T>
|
||||
}
|
||||
|
||||
const json = (body: unknown): RequestInit => ({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const api = {
|
||||
status: () => request<StatusResp>('/api/manager/status'),
|
||||
|
||||
canvas: () => request<CanvasData>('/api/manager/canvas'),
|
||||
saveCanvas: (data: CanvasData) =>
|
||||
request<CanvasData>('/api/manager/canvas', json(data)),
|
||||
|
||||
settings: () => request<Settings>('/api/manager/settings'),
|
||||
saveSettings: (s: Settings) =>
|
||||
request<Settings>('/api/manager/settings', json(s)),
|
||||
|
||||
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
|
||||
installBinary: (version?: string) =>
|
||||
request<InstallResult>('/api/manager/binary/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: version ? JSON.stringify({ version }) : undefined,
|
||||
}),
|
||||
|
||||
|
||||
saveRemote: (remote: Remote) =>
|
||||
request<Remote>('/api/manager/remotes', json(remote)),
|
||||
deleteRemote: (name: string) =>
|
||||
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
profileStart: (name: string) =>
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
profileStop: (name: string) =>
|
||||
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
profileRestart: (name: string) =>
|
||||
request<void>(
|
||||
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
profileConfig: (name: string) =>
|
||||
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
|
||||
profileLogs: (name: string) =>
|
||||
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
|
||||
}
|
||||
166
web/src/components/LocalNode.vue
Normal file
166
web/src/components/LocalNode.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="local-node" :class="{ selected, conflicted }">
|
||||
<!-- source handle (right) connects to remote -->
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
|
||||
<div class="node-head">
|
||||
<span class="node-icon">⬢</span>
|
||||
<input v-model="nameField" class="name-input" />
|
||||
<button
|
||||
class="del-btn"
|
||||
title="删除"
|
||||
@click.stop="emit('remove', props.data.name)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<label>IP <input v-model="ipField" /></label>
|
||||
<label>端口 <input v-model.number="portField" type="number" /></label>
|
||||
<label
|
||||
>协议
|
||||
<select v-model="protocolField">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import type { CanvasLocal } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CanvasLocal
|
||||
selected?: boolean
|
||||
conflicted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:data', data: CanvasLocal): void
|
||||
(e: 'remove', name: string): void
|
||||
}>()
|
||||
|
||||
const field = <K extends keyof CanvasLocal>(key: K) =>
|
||||
computed({
|
||||
get: () => props.data[key],
|
||||
set: (val: CanvasLocal[K]) => {
|
||||
emit('update:data', { ...props.data, [key]: val })
|
||||
},
|
||||
})
|
||||
|
||||
const nameField = field('name')
|
||||
const ipField = field('ip')
|
||||
const portField = field('port')
|
||||
const protocolField = field('protocol')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.local-node {
|
||||
min-width: 190px;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #4caf50;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2e7d32;
|
||||
&:focus {
|
||||
outline: 1px solid #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #81c784;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 64px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
330
web/src/components/PortEdge.vue
Normal file
330
web/src/components/PortEdge.vue
Normal file
@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<g class="port-edge" :class="{ selected }">
|
||||
<path
|
||||
:d="path"
|
||||
class="port-edge-path"
|
||||
:class="{ selected, conflicted }"
|
||||
fill="none"
|
||||
:stroke="strokeColor"
|
||||
:stroke-width="selected ? 3.5 : 2.5"
|
||||
/>
|
||||
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
|
||||
so users can place the port anywhere along/around the connection. -->
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
ref="labelEl"
|
||||
class="port-label"
|
||||
:class="{ dragging }"
|
||||
:style="labelStyle"
|
||||
@pointerdown.stop.prevent="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
>
|
||||
{{ displayPort }}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { EdgeLabelRenderer } from '@vue-flow/core'
|
||||
import type { EdgeProps } from '@vue-flow/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<EdgeProps & { conflicted?: boolean }>(),
|
||||
{
|
||||
selected: false,
|
||||
conflicted: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'label-click', payload: { edgeId: string }): void
|
||||
(
|
||||
e: 'label-drag',
|
||||
payload: { edgeId: string; offset: { x: number; y: number } },
|
||||
): void
|
||||
}>()
|
||||
|
||||
// User-dragged offset relative to the curve midpoint, initialized from the
|
||||
// edge data so it survives saves.
|
||||
const dragOffset = ref<{ x: number; y: number }>({
|
||||
x: Number(props.data?.offsetX ?? 0),
|
||||
y: Number(props.data?.offsetY ?? 0),
|
||||
})
|
||||
|
||||
// Press-to-drag using Pointer Events + pointer capture. Once the pointer is
|
||||
// captured on the label element, all subsequent pointer events (even outside
|
||||
// the element) are delivered to it, and pointerup reliably ends the drag.
|
||||
const labelEl = ref<HTMLElement | null>(null)
|
||||
const clickSlop = 2 // px of total travel that still counts as a click
|
||||
let moved = false
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startOff = { x: 0, y: 0 }
|
||||
|
||||
let dragging = false
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
// Never let this press reach the Vue Flow pane (which would pan the canvas).
|
||||
e.stopPropagation()
|
||||
dragging = true
|
||||
moved = false
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startOff = { ...dragOffset.value }
|
||||
if (labelEl.value) {
|
||||
try {
|
||||
labelEl.value.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// Capture can fail in rare cases; the pointermove/up listeners below
|
||||
// still work while the pointer stays over the label.
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
if (Math.abs(dx) > clickSlop || Math.abs(dy) > clickSlop) {
|
||||
moved = true
|
||||
}
|
||||
// The label lives in canvas (world) coordinates; pointer deltas are in
|
||||
// screen pixels, so divide by the current viewport zoom.
|
||||
const z = readZoom()
|
||||
dragOffset.value = {
|
||||
x: startOff.x + dx / z,
|
||||
y: startOff.y + dy / z,
|
||||
}
|
||||
emit('label-drag', {
|
||||
edgeId: props.id,
|
||||
offset: { ...dragOffset.value },
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
const wasDrag = dragging
|
||||
dragging = false
|
||||
if (labelEl.value && e.pointerId != null) {
|
||||
try {
|
||||
labelEl.value.releasePointerCapture?.(e.pointerId)
|
||||
} catch {
|
||||
// releasePointerCapture may throw if capture was never established.
|
||||
}
|
||||
}
|
||||
// Press without real movement = tap: open the port editor.
|
||||
if (!wasDrag) return
|
||||
if (!moved) {
|
||||
emit('label-click', { edgeId: props.id })
|
||||
}
|
||||
}
|
||||
|
||||
// readZoom parses the current zoom level from the Vue Flow viewport's
|
||||
// transform (e.g. "translate(10px, 20px) scale(0.85)"). Falls back to 1.
|
||||
function readZoom(): number {
|
||||
const vp = document.querySelector('.vue-flow__viewport') as HTMLElement | null
|
||||
if (!vp) return 1
|
||||
const m = /scale\(([0-9.]+)\)/.exec(vp.style.transform || '')
|
||||
if (!m) return 1
|
||||
const z = Number(m[1])
|
||||
return z > 0 ? z : 1
|
||||
}
|
||||
|
||||
// {{ layer }} is a per-pair index assigned by the canvas editor. It bends this
|
||||
// edge away from its siblings so multiple links between the same two nodes do
|
||||
// not overlap. Rendering order follows the layer too, so later links are drawn
|
||||
// above earlier ones.
|
||||
const layer = computed(() => Number(props.data?.layer ?? 0))
|
||||
|
||||
const displayPort = computed(() => {
|
||||
const t = String(props.label ?? '')
|
||||
return t || '?'
|
||||
})
|
||||
|
||||
// Direction of the base bend alternates so pairs of links spread symmetrically
|
||||
// above and below the straight line.
|
||||
const baseBend = computed(() => {
|
||||
const l = layer.value
|
||||
const dir = l % 2 === 0 ? 1 : -1
|
||||
const mag = (Math.floor(l / 2) + 1) * 16
|
||||
return dir * mag
|
||||
})
|
||||
|
||||
// totalBend adds the user's vertical drag onto the base layer bend so the
|
||||
// curve follows the dragged label.
|
||||
const totalBend = computed(() => baseBend.value + dragOffset.value.y)
|
||||
|
||||
const path = computed(() => {
|
||||
const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } =
|
||||
props
|
||||
return buildBentPath(
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
totalBend.value,
|
||||
)
|
||||
})
|
||||
|
||||
// The label sits at the curve midpoint, shifted horizontally by the user's
|
||||
// drag offset (vertical drag is already absorbed into totalBend).
|
||||
const mid = computed(() => midpointOf(path.value))
|
||||
|
||||
const labelStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
transform: `translate(${mid.value.x + dragOffset.value.x}px, ${mid.value.y}px) translate(-50%, -50%)`,
|
||||
pointerEvents: 'all' as const,
|
||||
cursor: 'grab',
|
||||
zIndex: 10,
|
||||
}))
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
if (props.conflicted) {
|
||||
return '#f5222d'
|
||||
}
|
||||
const l = layer.value
|
||||
// Muted, UI-friendly palette that matches the existing theme (Element Plus
|
||||
// primary / success / warning / danger / info), cycled per layer.
|
||||
const hues = [
|
||||
'#409eff',
|
||||
'#67c23a',
|
||||
'#e6a23c',
|
||||
'#f56c6c',
|
||||
'#909399',
|
||||
'#409eff',
|
||||
]
|
||||
return hues[l % hues.length]
|
||||
})
|
||||
|
||||
// buildBentPath draws a cubic bezier from the source handle to the target
|
||||
// handle, bending the control points vertically by (bend) pixels so parallel
|
||||
// links between the same nodes separate visually.
|
||||
function buildBentPath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
sourcePosition: unknown,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
targetPosition: unknown,
|
||||
bend: number,
|
||||
): string {
|
||||
const horizontal = Math.abs(targetX - sourceX)
|
||||
const handleLen = Math.max(horizontal * 0.45, 40)
|
||||
let sx = sourceX
|
||||
let sy = sourceY
|
||||
let tx = targetX
|
||||
let ty = targetY
|
||||
|
||||
if (sourcePosition === 'Right') {
|
||||
sx = sourceX
|
||||
} else if (sourcePosition === 'Left') {
|
||||
sx = sourceX - handleLen
|
||||
} else if (sourcePosition === 'Top') {
|
||||
sx = sourceX
|
||||
sy = sourceY
|
||||
} else {
|
||||
sx = sourceX
|
||||
}
|
||||
|
||||
if (targetPosition === 'Left') {
|
||||
tx = targetX
|
||||
} else if (targetPosition === 'Right') {
|
||||
tx = targetX + handleLen
|
||||
} else if (targetPosition === 'Top') {
|
||||
ty = targetY
|
||||
}
|
||||
|
||||
const c1x = sx + (tx - sx) * 0.5
|
||||
const c1y = sy + bend
|
||||
const c2x = tx - (tx - sx) * 0.5
|
||||
const c2y = ty + bend
|
||||
return `M ${sx} ${sy} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tx} ${ty}`
|
||||
}
|
||||
|
||||
// midpointOf approximates the curve center using the quadratic mean of the two
|
||||
// evaluated bezier points at t=0.5 (single evaluation is enough for labels).
|
||||
function midpointOf(path: string): { x: number; y: number } {
|
||||
// Parse the cubic bezier control points out of the path string.
|
||||
const nums = path
|
||||
.replace('M', '')
|
||||
.replace('C', '')
|
||||
.split(/[ ,]+/)
|
||||
.filter((s) => s.length > 0)
|
||||
.map(Number)
|
||||
if (nums.length < 8) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
const x0 = nums[0] ?? 0
|
||||
const y0 = nums[1] ?? 0
|
||||
const cx1 = nums[2] ?? 0
|
||||
const cy1 = nums[3] ?? 0
|
||||
const cx2 = nums[4] ?? 0
|
||||
const cy2 = nums[5] ?? 0
|
||||
const x1 = nums[6] ?? 0
|
||||
const y1 = nums[7] ?? 0
|
||||
const t = 0.5
|
||||
const mt = 1 - t
|
||||
const x =
|
||||
mt * mt * mt * x0 +
|
||||
3 * mt * mt * t * cx1 +
|
||||
3 * mt * t * t * cx2 +
|
||||
t * t * t * x1
|
||||
const y =
|
||||
mt * mt * mt * y0 +
|
||||
3 * mt * mt * t * cy1 +
|
||||
3 * mt * t * t * cy2 +
|
||||
t * t * t * y1
|
||||
return { x, y }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.port-edge-path {
|
||||
&.selected {
|
||||
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
|
||||
}
|
||||
&.conflicted {
|
||||
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.port-label {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: $color-text-primary;
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
web/src/components/RemoteNode.vue
Normal file
172
web/src/components/RemoteNode.vue
Normal file
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="remote-node" :class="{ selected, conflicted }">
|
||||
<!-- target handle (left) receives connections from locals -->
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
|
||||
<div class="node-head">
|
||||
<span class="node-icon">⬤</span>
|
||||
<input v-model="nameField" class="name-input" />
|
||||
<label class="enabled">
|
||||
<input v-model="enabledField" type="checkbox" />
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
class="del-btn"
|
||||
title="删除"
|
||||
@click.stop="emit('remove', props.data.name)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<label>IP <input v-model="ipField" /></label>
|
||||
<label>端口 <input v-model.number="portField" type="number" /></label>
|
||||
<label>令牌 <input v-model="tokenField" /></label>
|
||||
<label>URL <input v-model="urlField" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import type { CanvasRemote } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CanvasRemote
|
||||
selected?: boolean
|
||||
conflicted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:data', data: CanvasRemote): void
|
||||
(e: 'remove', name: string): void
|
||||
}>()
|
||||
|
||||
const field = <K extends keyof CanvasRemote>(key: K) =>
|
||||
computed({
|
||||
get: () => props.data[key],
|
||||
set: (val: CanvasRemote[K]) => {
|
||||
emit('update:data', { ...props.data, [key]: val })
|
||||
},
|
||||
})
|
||||
|
||||
const nameField = field('name')
|
||||
const ipField = field('ip')
|
||||
const portField = field('port')
|
||||
const tokenField = field('token')
|
||||
const urlField = field('url')
|
||||
const enabledField = field('enabled')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.remote-node {
|
||||
min-width: 200px;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: #fff3e0;
|
||||
border: 2px solid #ff9800;
|
||||
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #ff9800;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #e65100;
|
||||
&:focus {
|
||||
outline: 1px solid #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
.enabled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ffb74d;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #ef6c00;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #ffcc80;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 64px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
web/src/env.d.ts
vendored
Normal file
7
web/src/env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
40
web/src/styles/variables.scss
Normal file
40
web/src/styles/variables.scss
Normal file
@ -0,0 +1,40 @@
|
||||
// Global SCSS variables for webui4frpc (light theme).
|
||||
$color-text-primary: #303133;
|
||||
$color-text-secondary: #606266;
|
||||
$color-text-muted: #909399;
|
||||
$color-text-light: #c0c4cc;
|
||||
$color-bg-primary: #ffffff;
|
||||
$color-bg-secondary: #f9f9f9;
|
||||
$color-bg-tertiary: #fafafa;
|
||||
$color-bg-muted: #f4f4f5;
|
||||
$color-bg-hover: #efefef;
|
||||
$color-bg-active: #eaeaea;
|
||||
$color-border: #dcdfe6;
|
||||
$color-border-light: #e4e7ed;
|
||||
$color-border-lighter: #ebeef5;
|
||||
$color-border-extra-light: #f2f6fc;
|
||||
$color-primary: #409eff;
|
||||
$color-success: #67c23a;
|
||||
$color-warning: #e6a23c;
|
||||
$color-danger: #f56c6c;
|
||||
$color-info: #909399;
|
||||
$color-btn-primary: #303133;
|
||||
$color-btn-primary-hover: #4a4d5c;
|
||||
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 13px;
|
||||
$font-size-md: 14px;
|
||||
$font-size-lg: 16px;
|
||||
$font-size-xl: 18px;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
$radius-sm: 6px;
|
||||
$radius-md: 10px;
|
||||
$radius-lg: 14px;
|
||||
$transition-fast: 0.2s ease;
|
||||
96
web/src/types.ts
Normal file
96
web/src/types.ts
Normal file
@ -0,0 +1,96 @@
|
||||
// Data models shared with the Go backend.
|
||||
|
||||
// CanvasLocal / CanvasRemote are aliases kept for node components migrated
|
||||
// from the original prototype; they equal Local / Remote.
|
||||
export type CanvasLocal = Local
|
||||
export type CanvasRemote = Remote
|
||||
|
||||
// CanvasLink mirrors the store.Link JSON shape used by the canvas editor.
|
||||
export type CanvasLink = Link
|
||||
|
||||
export interface Local {
|
||||
name: string
|
||||
ip: string
|
||||
port: number
|
||||
protocol: string // tcp | udp | http | https
|
||||
}
|
||||
|
||||
export interface Remote {
|
||||
name: string
|
||||
ip: string
|
||||
port: number // frps connect port
|
||||
token?: string
|
||||
url?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id?: number
|
||||
local: string
|
||||
remote: string
|
||||
remotePort: number
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
locals: Local[]
|
||||
remotes: Remote[]
|
||||
links: Link[]
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
autoStartProfiles: boolean
|
||||
restartOnExit: boolean
|
||||
restartIntervalSeconds: number
|
||||
binaryPath?: string
|
||||
}
|
||||
|
||||
export interface ProcessStatus {
|
||||
state: string
|
||||
pid?: number
|
||||
startTime?: number
|
||||
restartCount: number
|
||||
exitCode?: number
|
||||
err?: string
|
||||
}
|
||||
|
||||
export interface StatusProfile {
|
||||
name: string
|
||||
enabled: boolean
|
||||
process: ProcessStatus
|
||||
hasProcess: boolean
|
||||
forwards: { service: string; remotePort: number; localPort?: number }[]
|
||||
}
|
||||
|
||||
export interface StatusResp {
|
||||
version: string
|
||||
workDir: string
|
||||
settings: Settings
|
||||
services: Local[]
|
||||
remotes: Remote[]
|
||||
binaryPath: string
|
||||
profiles: StatusProfile[]
|
||||
localStatus: LocalStatus[]
|
||||
}
|
||||
|
||||
export interface LocalTargetStatus {
|
||||
remote: string
|
||||
remotePort: number
|
||||
workerState: string
|
||||
}
|
||||
|
||||
export interface LocalStatus {
|
||||
local: Local
|
||||
targets: LocalTargetStatus[]
|
||||
}
|
||||
|
||||
export interface BinaryStatus {
|
||||
binaryPath: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
path: string
|
||||
version: string
|
||||
}
|
||||
785
web/src/views/CanvasView.vue
Normal file
785
web/src/views/CanvasView.vue
Normal file
@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<div class="canvas-editor">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">连接配置</span>
|
||||
<button class="btn local-add" @click="addLocal">+ 本地转发项</button>
|
||||
<button class="btn remote-add" @click="addRemote">+ 远程节点</button>
|
||||
<button class="btn warn" @click="autoLayout">自动排列</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
|
||||
<button class="btn save" :disabled="saving" @click="save">
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-wrap">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:default-viewport="{ zoom: 0.85 }"
|
||||
:min-zoom="0.3"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
:delete-key-code="null"
|
||||
:edges-updatable="false"
|
||||
:edges-reconnectable="false"
|
||||
class="flow-canvas"
|
||||
@connect="onConnect"
|
||||
@pane-click="deselectAll"
|
||||
@edge-click="onEdgeClick"
|
||||
>
|
||||
<Background pattern-color="#cfd8dc" :gap="20" />
|
||||
|
||||
<template #node-local="slotProps">
|
||||
<LocalNode
|
||||
:data="(slotProps as any).data"
|
||||
:selected="Boolean((slotProps as any).selected)"
|
||||
:conflicted="isLocalConflicted((slotProps as any).data?.name)"
|
||||
@remove="removeLocal"
|
||||
@update:data="onLocalData"
|
||||
/>
|
||||
</template>
|
||||
<template #node-remote="slotProps">
|
||||
<RemoteNode
|
||||
:data="(slotProps as any).data"
|
||||
:selected="Boolean((slotProps as any).selected)"
|
||||
:conflicted="isRemoteConflicted((slotProps as any).data?.name)"
|
||||
@remove="removeRemote"
|
||||
@update:data="onRemoteData"
|
||||
/>
|
||||
</template>
|
||||
<template #edge-portedge="edgeProps">
|
||||
<PortEdge
|
||||
v-bind="edgeProps as any"
|
||||
:conflicted="isEdgeConflicted((edgeProps as any).id)"
|
||||
@label-click="onEdgeLabelClick"
|
||||
@label-drag="onLabelDrag"
|
||||
/>
|
||||
</template>
|
||||
</VueFlow>
|
||||
</div>
|
||||
|
||||
<!-- Connect dialog (port editor) -->
|
||||
<el-dialog
|
||||
v-model="portDlg.visible"
|
||||
:title="portDlg.isNew ? '新建连线' : '编辑远程端口'"
|
||||
width="400px"
|
||||
>
|
||||
<div class="dlg-row">
|
||||
<label>连接</label>
|
||||
<span class="dlg-value"
|
||||
>{{ portDlg.local }} → {{ portDlg.remote }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="dlg-row">
|
||||
<label>远程端口</label>
|
||||
<el-input
|
||||
v-model="portInput"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="默认 = 本地端口"
|
||||
class="port-input"
|
||||
@keyup.enter="confirmPort"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn" @click="portDlg.visible = false">取消</button>
|
||||
<button class="btn save" @click="confirmPort">确定</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { Edge, Connection } from '@vue-flow/core'
|
||||
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
|
||||
import LocalNode from '../components/LocalNode.vue'
|
||||
import RemoteNode from '../components/RemoteNode.vue'
|
||||
import PortEdge from '../components/PortEdge.vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const nodes = ref<any[]>([])
|
||||
const edges = ref<Edge[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// ---- canvas conflict validation ----
|
||||
interface CanvasConflict {
|
||||
type: 'port' | 'domain'
|
||||
remote: string
|
||||
remotePort?: number
|
||||
domain?: string
|
||||
locals: string[] // the conflicting local service names
|
||||
edgeIds: string[]
|
||||
}
|
||||
|
||||
const conflicts = ref<CanvasConflict[]>([])
|
||||
const conflictedEdgeIds = computed(() => {
|
||||
const s = new Set<string>()
|
||||
for (const c of conflicts.value) {
|
||||
for (const id of c.edgeIds) s.add(id)
|
||||
}
|
||||
return s
|
||||
})
|
||||
const saving = ref(false)
|
||||
const dirty = ref(false)
|
||||
|
||||
const portDlg = ref({
|
||||
visible: false,
|
||||
isNew: false,
|
||||
local: '',
|
||||
remote: '',
|
||||
remotePort: 0,
|
||||
pendingEdge: null as Edge | null,
|
||||
})
|
||||
|
||||
// portInput is the plain text control for the remote port in the dialog.
|
||||
const portInput = ref('')
|
||||
|
||||
// openPortDlg opens the port editor dialog, seeding the input.
|
||||
const openPortDlg = (
|
||||
local: string,
|
||||
remote: string,
|
||||
port: number,
|
||||
isNew: boolean,
|
||||
pending?: Edge | null,
|
||||
) => {
|
||||
portDlg.value = {
|
||||
visible: true,
|
||||
isNew,
|
||||
local,
|
||||
remote,
|
||||
remotePort: port,
|
||||
pendingEdge: pending ?? null,
|
||||
}
|
||||
portInput.value = String(port)
|
||||
}
|
||||
|
||||
const localOf = (name: string) =>
|
||||
nodes.value.find((n) => n.type === 'local' && n.data?.name === name)?.data as
|
||||
CanvasLocal | undefined
|
||||
|
||||
// validateCanvas checks every remote node for conflicts among the links that
|
||||
// arrive at it: duplicate remote ports (tcp/udp) or duplicate domains
|
||||
// (http/https). Returns the list of conflicts and stores it in conflicts.
|
||||
const validateCanvas = (): CanvasConflict[] => {
|
||||
const found: CanvasConflict[] = []
|
||||
const byRemote = new Map<string, { edgeId: string; local: string; remotePort: number; type: string }[]>()
|
||||
|
||||
for (const e of edges.value) {
|
||||
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
|
||||
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
|
||||
if (!localName || !remoteName) continue
|
||||
const loc = localOf(localName)
|
||||
const rp = Number(e.label)
|
||||
if (!loc || isNaN(rp) || rp <= 0) continue
|
||||
if (!byRemote.has(remoteName)) byRemote.set(remoteName, [])
|
||||
byRemote.get(remoteName)!.push({
|
||||
edgeId: e.id,
|
||||
local: localName,
|
||||
remotePort: rp,
|
||||
type: loc.protocol,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [remote, items] of byRemote) {
|
||||
// 1) duplicate remote ports among tcp/udp links
|
||||
const byPort = new Map<number, { local: string; edgeId: string }[]>()
|
||||
for (const it of items) {
|
||||
if (it.type !== 'tcp' && it.type !== 'udp') continue
|
||||
if (!byPort.has(it.remotePort)) byPort.set(it.remotePort, [])
|
||||
byPort.get(it.remotePort)!.push({ local: it.local, edgeId: it.edgeId })
|
||||
}
|
||||
for (const [port, arr] of byPort) {
|
||||
if (arr.length < 2) continue
|
||||
found.push({
|
||||
type: 'port',
|
||||
remote,
|
||||
remotePort: port,
|
||||
locals: arr.map((a) => a.local),
|
||||
edgeIds: arr.map((a) => a.edgeId),
|
||||
})
|
||||
}
|
||||
|
||||
// 2) duplicate domains among http/https links (same local name currently
|
||||
// maps to <name>.local).
|
||||
const byDomain = new Map<string, { local: string; edgeId: string }[]>()
|
||||
for (const it of items) {
|
||||
if (it.type !== 'http' && it.type !== 'https') continue
|
||||
const domain = it.local + '.local'
|
||||
if (!byDomain.has(domain)) byDomain.set(domain, [])
|
||||
byDomain.get(domain)!.push({ local: it.local, edgeId: it.edgeId })
|
||||
}
|
||||
for (const [domain, arr] of byDomain) {
|
||||
if (arr.length < 2) continue
|
||||
found.push({
|
||||
type: 'domain',
|
||||
remote,
|
||||
domain,
|
||||
locals: arr.map((a) => a.local),
|
||||
edgeIds: arr.map((a) => a.edgeId),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
conflicts.value = found
|
||||
return found
|
||||
}
|
||||
|
||||
const isRemoteConflicted = (name: string): boolean =>
|
||||
conflicts.value.some((c) => c.remote === name)
|
||||
|
||||
const isLocalConflicted = (name: string): boolean =>
|
||||
conflicts.value.some((c) => c.locals.includes(name))
|
||||
|
||||
const isEdgeConflicted = (id: string): boolean =>
|
||||
conflictedEdgeIds.value.has(id)
|
||||
|
||||
const nodeId = (kind: 'local' | 'remote', name: string) => `${kind}::${name}`
|
||||
|
||||
const addLocal = () => {
|
||||
const name = `local-${nodes.value.filter((n) => n.type === 'local').length + 1}`
|
||||
const data: CanvasLocal = {
|
||||
name,
|
||||
ip: '127.0.0.1',
|
||||
port: 8080,
|
||||
protocol: 'tcp',
|
||||
}
|
||||
nodes.value.push({
|
||||
id: nodeId('local', name),
|
||||
type: 'local',
|
||||
position: {
|
||||
x: 60,
|
||||
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
|
||||
},
|
||||
data,
|
||||
})
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
const addRemote = () => {
|
||||
const name = `remote-${nodes.value.filter((n) => n.type === 'remote').length + 1}`
|
||||
const data: CanvasRemote = {
|
||||
name,
|
||||
ip: '127.0.0.1',
|
||||
port: 7000,
|
||||
token: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
}
|
||||
nodes.value.push({
|
||||
id: nodeId('remote', name),
|
||||
type: 'remote',
|
||||
position: {
|
||||
x: 760,
|
||||
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
|
||||
},
|
||||
data,
|
||||
})
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
const removeLocal = (name: string) => {
|
||||
nodes.value = nodes.value.filter(
|
||||
(n) => !(n.type === 'local' && n.data?.name === name),
|
||||
)
|
||||
edges.value = edges.value.filter((e) => e.source !== nodeId('local', name))
|
||||
dirty.value = true
|
||||
validateCanvas()
|
||||
}
|
||||
|
||||
const removeRemote = (name: string) => {
|
||||
nodes.value = nodes.value.filter(
|
||||
(n) => !(n.type === 'remote' && n.data?.name === name),
|
||||
)
|
||||
edges.value = edges.value.filter((e) => e.target !== nodeId('remote', name))
|
||||
dirty.value = true
|
||||
validateCanvas()
|
||||
}
|
||||
|
||||
// edgeId builds a unique id for a link. The remote port is part of the id so
|
||||
// that several links between the same local and remote nodes (different ports)
|
||||
// are kept separate instead of being merged into one edge.
|
||||
const edgeId = (source: string, target: string, remotePort: number) =>
|
||||
`${source}-->${target}@${remotePort}`
|
||||
|
||||
const onConnect = (conn: Connection) => {
|
||||
const localName = conn.source.startsWith('local::')
|
||||
? conn.source.slice(7)
|
||||
: ''
|
||||
const remoteName = conn.target.startsWith('remote::')
|
||||
? conn.target.slice(8)
|
||||
: ''
|
||||
if (!localName || !remoteName) return
|
||||
const loc = localOf(localName)
|
||||
if (!loc) return
|
||||
openPortDlg(localName, remoteName, loc.port, true, {
|
||||
id: edgeId(conn.source, conn.target, loc.port),
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
} as Edge)
|
||||
}
|
||||
|
||||
const confirmPort = () => {
|
||||
const d = portDlg.value
|
||||
const parsed = Number(portInput.value)
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
ElMessage.warning('请输入 1-65535 的整数端口')
|
||||
return
|
||||
}
|
||||
d.remotePort = parsed
|
||||
if (d.isNew && d.pendingEdge) {
|
||||
const newId = edgeId(
|
||||
d.pendingEdge.source,
|
||||
d.pendingEdge.target,
|
||||
d.remotePort,
|
||||
)
|
||||
const existing = edges.value.find((e) => e.id === newId)
|
||||
if (existing) {
|
||||
existing.label = String(d.remotePort)
|
||||
} else {
|
||||
edges.value.push({
|
||||
id: newId,
|
||||
source: d.pendingEdge.source,
|
||||
target: d.pendingEdge.target,
|
||||
label: String(d.remotePort),
|
||||
type: 'portedge',
|
||||
animated: false,
|
||||
data: {},
|
||||
})
|
||||
assignLayers()
|
||||
}
|
||||
} else if (d.pendingEdge) {
|
||||
// Editing an existing link's remote port: update its label and id so the
|
||||
// edge stays unique when the port changes.
|
||||
const old = d.pendingEdge
|
||||
const newId = edgeId(old.source, old.target, d.remotePort)
|
||||
const conflict = edges.value.find((e) => e.id === newId && e !== old)
|
||||
if (conflict) {
|
||||
ElMessage.warning('该端口已被同一条连线占用')
|
||||
return
|
||||
}
|
||||
if (old.id !== newId) {
|
||||
old.id = newId
|
||||
}
|
||||
old.label = String(d.remotePort)
|
||||
}
|
||||
portDlg.value.visible = false
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
const editEdge = (edge: Edge) => {
|
||||
const localName = edge.source.startsWith('local::')
|
||||
? edge.source.slice(7)
|
||||
: ''
|
||||
const remoteName = edge.target.startsWith('remote::')
|
||||
? edge.target.slice(8)
|
||||
: ''
|
||||
const loc = localOf(localName)
|
||||
openPortDlg(
|
||||
localName,
|
||||
remoteName,
|
||||
Number(edge.label) || (loc ? loc.port : 0),
|
||||
false,
|
||||
edge,
|
||||
)
|
||||
}
|
||||
|
||||
// onEdgeLabelClick finds the edge by id and opens its port editor.
|
||||
const onEdgeLabelClick = ({ edgeId }: { edgeId: string }) => {
|
||||
const e = edges.value.find((x) => x.id === edgeId)
|
||||
if (e) editEdge(e)
|
||||
}
|
||||
|
||||
// onLabelDrag stores the user's port label offset on the edge so the custom
|
||||
// edge re-renders the curve and label at the dragged position, and saves it.
|
||||
const onLabelDrag = ({
|
||||
edgeId,
|
||||
offset,
|
||||
}: {
|
||||
edgeId: string
|
||||
offset: { x: number; y: number }
|
||||
}) => {
|
||||
const e = edges.value.find((x) => x.id === edgeId)
|
||||
if (e) {
|
||||
e.data = { ...(e.data as object), offsetX: offset.x, offsetY: offset.y }
|
||||
dirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// assignLayers re-computes the layer (per-pair index) of every edge so custom
|
||||
// port edges bend away from their siblings. Returns the same array mutated in
|
||||
// place; also used to keep ordering consistent.
|
||||
const assignLayers = () => {
|
||||
const seen = new Map<string, number>()
|
||||
for (const e of edges.value) {
|
||||
const key = `${e.source}||${e.target}`
|
||||
const n = seen.get(key) ?? 0
|
||||
seen.set(key, n + 1)
|
||||
e.data = { ...(e.data as object), layer: n }
|
||||
}
|
||||
// Re-run availability checks after the canvas topology changes so conflicts
|
||||
// are flagged (red) immediately.
|
||||
validateCanvas()
|
||||
}
|
||||
|
||||
const onLocalData = (data: CanvasLocal) => {
|
||||
const n = nodes.value.find(
|
||||
(x) => x.type === 'local' && x.data?.name === data.name,
|
||||
)
|
||||
if (n) n.data = { ...data }
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
const onRemoteData = (data: CanvasRemote) => {
|
||||
const n = nodes.value.find(
|
||||
(x) => x.type === 'remote' && x.data?.name === data.name,
|
||||
)
|
||||
if (n) n.data = { ...data }
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
const onEdgeClick = ({ edge }: { edge: Edge }) => {
|
||||
editEdge(edge)
|
||||
}
|
||||
|
||||
// checkAndWarn validates the canvas availability (port/domain conflicts)
|
||||
// and shows a dialog listing the offending local -> remote forwards.
|
||||
const checkAndWarn = () => {
|
||||
const found = validateCanvas()
|
||||
if (found.length === 0) {
|
||||
return true
|
||||
}
|
||||
const lines = found.map((c) => {
|
||||
const loc = c.locals.join('、')
|
||||
if (c.type === 'port') {
|
||||
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:远程端口 ${c.remotePort} 重复`
|
||||
}
|
||||
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:域名 ${c.domain} 重复(一个域名只能绑定一个 http/https 服务)`
|
||||
})
|
||||
void ElMessageBox.alert(
|
||||
`<div class="conflict-box">${lines.join('<br/>')}</div>`,
|
||||
'发现连接冲突,请检查标红的节点与连线',
|
||||
{
|
||||
confirmButtonText: '知道了',
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: 'warning',
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
validateCanvas() // 点击画布:可用性检查(标红)
|
||||
nodes.value.forEach((n) => {
|
||||
n.selected = false
|
||||
})
|
||||
}
|
||||
|
||||
const localNodes = computed(() => nodes.value.filter((n) => n.type === 'local'))
|
||||
const remoteNodes = computed(() =>
|
||||
nodes.value.filter((n) => n.type === 'remote'),
|
||||
)
|
||||
|
||||
const autoLayout = () => {
|
||||
let li = 0
|
||||
let ri = 0
|
||||
for (const n of nodes.value) {
|
||||
if (n.type === 'local') {
|
||||
n.position = { x: 60, y: 90 + li * 140 }
|
||||
li++
|
||||
} else {
|
||||
n.position = { x: 760, y: 90 + ri * 160 }
|
||||
ri++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.canvas()
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
let li = 0
|
||||
let ri = 0
|
||||
for (const l of data.locals || []) {
|
||||
nodes.value.push({
|
||||
id: nodeId('local', l.name),
|
||||
type: 'local',
|
||||
position: { x: 60, y: 90 + li * 140 },
|
||||
data: { ...l },
|
||||
})
|
||||
li++
|
||||
}
|
||||
for (const r of data.remotes || []) {
|
||||
nodes.value.push({
|
||||
id: nodeId('remote', r.name),
|
||||
type: 'remote',
|
||||
position: { x: 760, y: 90 + ri * 160 },
|
||||
data: { ...r },
|
||||
})
|
||||
ri++
|
||||
}
|
||||
for (const link of data.links || []) {
|
||||
const s = nodeId('local', link.local)
|
||||
const t = nodeId('remote', link.remote)
|
||||
if (
|
||||
!nodes.value.some((n) => n.id === s) ||
|
||||
!nodes.value.some((n) => n.id === t)
|
||||
)
|
||||
continue
|
||||
const rp = link.remotePort || localOf(link.local)?.port || 0
|
||||
edges.value.push({
|
||||
id: edgeId(s, t, rp),
|
||||
source: s,
|
||||
target: t,
|
||||
label: String(rp),
|
||||
type: 'portedge',
|
||||
animated: false,
|
||||
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
|
||||
})
|
||||
}
|
||||
assignLayers()
|
||||
dirty.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayload = () => {
|
||||
const locals: CanvasLocal[] = localNodes.value.map((n) => ({
|
||||
...(n.data as CanvasLocal),
|
||||
}))
|
||||
const remotes: CanvasRemote[] = remoteNodes.value.map((n) => ({
|
||||
...(n.data as CanvasRemote),
|
||||
}))
|
||||
const links: CanvasLink[] = []
|
||||
for (const e of edges.value) {
|
||||
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
|
||||
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
|
||||
const rp = Number(e.label)
|
||||
links.push({
|
||||
local: localName,
|
||||
remote: remoteName,
|
||||
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
|
||||
offsetX: (e.data as any)?.offsetX ?? 0,
|
||||
offsetY: (e.data as any)?.offsetY ?? 0,
|
||||
})
|
||||
}
|
||||
return { locals, remotes, links }
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
// 保存前可用性检查:有冲突则提示并阻止保存,保证配置可直接生效
|
||||
if (!checkAndWarn()) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = await api.saveCanvas(buildPayload())
|
||||
const posOf = new Map(nodes.value.map((n) => [n.id, n.position]))
|
||||
nodes.value = []
|
||||
let li = 0
|
||||
let ri = 0
|
||||
for (const l of data.locals || []) {
|
||||
const id = nodeId('local', l.name)
|
||||
nodes.value.push({
|
||||
id,
|
||||
type: 'local',
|
||||
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
|
||||
data: { ...l },
|
||||
})
|
||||
li++
|
||||
}
|
||||
for (const r of data.remotes || []) {
|
||||
const id = nodeId('remote', r.name)
|
||||
nodes.value.push({
|
||||
id,
|
||||
type: 'remote',
|
||||
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
|
||||
data: { ...r },
|
||||
})
|
||||
ri++
|
||||
}
|
||||
edges.value = []
|
||||
for (const link of data.links || []) {
|
||||
const s = nodeId('local', link.local)
|
||||
const t = nodeId('remote', link.remote)
|
||||
const rp = link.remotePort || localOf(link.local)?.port || 0
|
||||
edges.value.push({
|
||||
id: edgeId(s, t, rp),
|
||||
source: s,
|
||||
target: t,
|
||||
label: String(rp),
|
||||
type: 'portedge',
|
||||
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
|
||||
})
|
||||
}
|
||||
assignLayers()
|
||||
dirty.value = false
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.message || e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.canvas-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $color-bg-secondary;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: $color-bg-primary;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-lg;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-primary;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.local-add {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #43a047;
|
||||
}
|
||||
}
|
||||
.btn.remote-add {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #f57c00;
|
||||
}
|
||||
}
|
||||
.btn.warn {
|
||||
background: #78909c;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #607d8b;
|
||||
}
|
||||
}
|
||||
.btn.save {
|
||||
background: $color-btn-primary;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: $color-btn-primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.flow-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.flow-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
|
||||
.dlg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
min-width: 70px;
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.dlg-value {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.dlg-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__edge-label) {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__handle) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__node) {
|
||||
cursor: grab;
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
319
web/src/views/SettingsView.vue
Normal file
319
web/src/views/SettingsView.vue
Normal file
@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-top">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">运行时设置</h2>
|
||||
<p class="page-subtitle">worker 二进制与运行策略</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="page-content">
|
||||
<!-- frpc 二进制 -->
|
||||
<section class="panel">
|
||||
<h3 class="panel-title">frpc 可执行文件</h3>
|
||||
<p class="panel-desc">
|
||||
用于拉起各 remote profile 的 frpc worker
|
||||
进程。留空则使用管理器自身进程。
|
||||
</p>
|
||||
|
||||
<div class="binary-row">
|
||||
<el-input
|
||||
v-model="binaryPathInput"
|
||||
placeholder="如 /usr/local/bin/frpc,留空用当前进程"
|
||||
clearable
|
||||
size="large"
|
||||
class="binary-input"
|
||||
@blur="applyBinaryPath"
|
||||
@keyup.enter="applyBinaryPath"
|
||||
/>
|
||||
<button
|
||||
class="btn install-btn"
|
||||
:disabled="installing"
|
||||
@click="installBinary"
|
||||
>
|
||||
<el-icon v-if="installing" class="spin"><Loading /></el-icon>
|
||||
{{ installing ? '安装中…' : '一键安装 frpc' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="binaryStatus" class="binary-info">
|
||||
<span class="info-label">当前路径</span>
|
||||
<code class="info-value">{{ binaryStatus.binaryPath }}</code>
|
||||
<span v-if="binaryStatus.version" class="info-version">
|
||||
{{ binaryStatus.version }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 运行策略 -->
|
||||
<section class="panel">
|
||||
<h3 class="panel-title">运行策略</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-text">
|
||||
<span class="setting-label">自动启动 profile</span>
|
||||
<span class="setting-hint"
|
||||
>管理器启动时自动拉起所有已启用的 remote</span
|
||||
>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.autoStartProfiles"
|
||||
@change="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-text">
|
||||
<span class="setting-label">异常退出自动重启</span>
|
||||
<span class="setting-hint">worker 崩溃后带退避重启</span>
|
||||
</div>
|
||||
<el-switch v-model="settings.restartOnExit" @change="saveSettings" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-text">
|
||||
<span class="setting-label">重启间隔(秒)</span>
|
||||
<span class="setting-hint">崩溃重启的指数退避基数</span>
|
||||
</div>
|
||||
<el-input-number
|
||||
v-model="settings.restartIntervalSeconds"
|
||||
:min="1"
|
||||
:max="3600"
|
||||
@change="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { api } from '../api'
|
||||
import type {
|
||||
BinaryStatus,
|
||||
Settings as ManagerSettings,
|
||||
} from '../types'
|
||||
|
||||
const loading = ref(false)
|
||||
const installing = ref(false)
|
||||
const binaryPathInput = ref('')
|
||||
const binaryStatus = ref<BinaryStatus | null>(null)
|
||||
const settings = ref<ManagerSettings>({
|
||||
autoStartProfiles: true,
|
||||
restartOnExit: true,
|
||||
restartIntervalSeconds: 5,
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
settings.value = await api.settings()
|
||||
binaryPathInput.value = settings.value.binaryPath || ''
|
||||
try {
|
||||
binaryStatus.value = await api.binaryStatus()
|
||||
} catch {
|
||||
binaryStatus.value = null
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyBinaryPath = async () => {
|
||||
try {
|
||||
const s = await api.settings()
|
||||
s.binaryPath = binaryPathInput.value.trim()
|
||||
await api.saveSettings(s)
|
||||
settings.value = s
|
||||
const saved = binaryPathInput.value.trim()
|
||||
ElMessage.success(
|
||||
saved ? `worker 将使用 ${saved}` : 'worker 将使用管理器自身(默认)',
|
||||
)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存 frpc 路径失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const installBinary = async () => {
|
||||
installing.value = true
|
||||
try {
|
||||
const result = await api.installBinary()
|
||||
binaryPathInput.value = result.path
|
||||
ElMessage.success(`已安装 frpc ${result.version}`)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('安装失败: ' + (e.message || e))
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.saveSettings(settings.value)
|
||||
ElMessage.success('运行策略已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-top {
|
||||
flex-shrink: 0;
|
||||
padding: $spacing-lg $spacing-xl;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: $spacing-lg $spacing-xl;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.panel-desc {
|
||||
margin: 0 0 $spacing-md;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.binary-row {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.binary-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
background: $color-btn-primary;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: $color-btn-primary-hover;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.binary-info {
|
||||
margin-top: $spacing-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.info-label {
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
background: $color-bg-muted;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: $font-size-sm;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-version {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: $color-primary;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1px solid $color-border-lighter;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
497
web/src/views/StatusView.vue
Normal file
497
web/src/views/StatusView.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="status-page">
|
||||
<div class="status-top">
|
||||
<h2 class="page-title">状态</h2>
|
||||
<span class="refresh-hint">每 5 秒自动刷新</span>
|
||||
<button class="refresh-btn" @click="loadStatus">刷新</button>
|
||||
<button class="add-btn" @click="openAdd">+ 添加远程节点</button>
|
||||
</div>
|
||||
|
||||
<div class="status-body" v-if="status">
|
||||
<!-- 远程节点状态 -->
|
||||
<section class="status-section">
|
||||
<h3 class="status-title">远程节点状态</h3>
|
||||
<div class="status-cards">
|
||||
<div
|
||||
v-for="p in status.profiles"
|
||||
:key="p.name"
|
||||
class="node-status-card"
|
||||
:class="{ healthy: workerHealthy(p.process.state) }"
|
||||
>
|
||||
<div class="ns-head">
|
||||
<span class="ns-name">{{ p.name }}</span>
|
||||
<span class="ns-badge" :class="{ ok: workerHealthy(p.process.state) }">
|
||||
● {{ workerStateLabel(p.process.state) }}
|
||||
</span>
|
||||
<span class="ns-actions">
|
||||
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">停止</button>
|
||||
<button v-else class="mini-btn" @click="startRemote(p.name)">启动</button>
|
||||
<button class="mini-btn" @click="openEdit(p)">编辑</button>
|
||||
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ns-meta">
|
||||
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
|
||||
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
|
||||
</div>
|
||||
<div class="ns-forwards" v-if="p.forwards.length">
|
||||
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
|
||||
{{ f.service }} → :{{ f.remotePort }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 本地服务转发状态 -->
|
||||
<section class="status-section">
|
||||
<h3 class="status-title">本地服务转发</h3>
|
||||
<div class="local-status-list">
|
||||
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-status-card">
|
||||
<div class="ls-head">
|
||||
<span class="ls-name">{{ ls.local.name }}</span>
|
||||
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
|
||||
<span class="ls-proto">{{ ls.local.protocol }}</span>
|
||||
</div>
|
||||
<div class="ls-targets">
|
||||
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-target">
|
||||
<span class="ls-arrow">→</span>
|
||||
<span class="ls-remote">{{ t.remote }}</span>
|
||||
<span class="ls-port">:{{ t.remotePort }}</span>
|
||||
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
|
||||
{{ workerStateLabel(t.workerState) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!status.localStatus.length" class="ls-empty">尚未配置本地服务</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<el-input v-model="dialog.remote.name" :disabled="!dialog.isNew" placeholder="如 aliyun-hz" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>服务器 IP</label>
|
||||
<el-input v-model="dialog.remote.ip" placeholder="frps 地址" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>端口</label>
|
||||
<el-input-number v-model="dialog.remote.port" :min="1" :max="65535" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Token</label>
|
||||
<el-input v-model="dialog.remote.token" placeholder="可选" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL</label>
|
||||
<el-input v-model="dialog.remote.url" placeholder="可选" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>启用</label>
|
||||
<el-switch v-model="dialog.remote.enabled" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="refresh-btn" @click="dialog.visible = false">取消</button>
|
||||
<button class="add-btn" @click="saveRemote">保存</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import type { Remote, StatusResp } from '../types'
|
||||
|
||||
const status = ref<StatusResp | null>(null)
|
||||
let timer: number | null = null
|
||||
|
||||
interface RemoteDialog {
|
||||
visible: boolean
|
||||
isNew: boolean
|
||||
remote: Remote
|
||||
}
|
||||
|
||||
const newRemote = (): Remote => ({
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 7000,
|
||||
token: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const dialog = reactive<RemoteDialog>({
|
||||
visible: false,
|
||||
isNew: true,
|
||||
remote: newRemote(),
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
dialog.isNew = true
|
||||
dialog.remote = newRemote()
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
const openEdit = (p: { name: string }) => {
|
||||
const found = status.value?.remotes.find((r) => r.name === p.name)
|
||||
dialog.isNew = false
|
||||
dialog.remote = found
|
||||
? { ...found }
|
||||
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
const saveRemote = async () => {
|
||||
const d = dialog.remote
|
||||
if (!d.name || !d.ip || d.port <= 0) {
|
||||
ElMessage.warning('请填写名称、IP、端口')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.saveRemote(d)
|
||||
dialog.visible = false
|
||||
ElMessage.success(dialog.isNew ? '节点已添加' : '节点已更新')
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const startRemote = async (name: string) => {
|
||||
try {
|
||||
await api.profileStart(name)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('启动失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const stopRemote = async (name: string) => {
|
||||
try {
|
||||
await api.profileStop(name)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('停止失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const removeRemote = async (name: string) => {
|
||||
if (!window.confirm(`删除远程节点 ${name}?`)) return
|
||||
try {
|
||||
await api.deleteRemote(name)
|
||||
await loadStatus()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('删除失败: ' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
status.value = await api.status()
|
||||
} catch {
|
||||
// keep last known status on transient errors
|
||||
}
|
||||
}
|
||||
|
||||
const workerStateLabel = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return '通畅'
|
||||
case 'starting':
|
||||
return '启动中'
|
||||
case 'restarting':
|
||||
return '重启中'
|
||||
case 'crashed':
|
||||
return '异常'
|
||||
default:
|
||||
return '未启动'
|
||||
}
|
||||
}
|
||||
|
||||
const workerHealthy = (s?: string) => s === 'running'
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
timer = window.setInterval(loadStatus, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-btn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
background: $color-btn-primary;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: $color-btn-primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 5px;
|
||||
padding: 1px 8px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.ns-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-top {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-hint {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 6px;
|
||||
padding: 3px 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.status-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-cards,
|
||||
.local-status-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.node-status-card {
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #fafafa;
|
||||
border-left: 4px solid $color-danger;
|
||||
|
||||
&.healthy {
|
||||
border-left-color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.ns-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ns-name {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ns-badge {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
background: #fef0f0;
|
||||
color: $color-danger;
|
||||
|
||||
&.ok {
|
||||
background: #f0f9eb;
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.ns-meta {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ns-err {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.ns-forwards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ns-fwd {
|
||||
font-size: 11px;
|
||||
background: $color-bg-muted;
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.ns-empty,
|
||||
.ls-empty {
|
||||
color: $color-text-muted;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.local-status-card {
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ls-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ls-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ls-addr {
|
||||
font-size: 12px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.ls-proto {
|
||||
font-size: 11px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.ls-targets {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ls-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ls-arrow {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.ls-remote {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-port {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.ls-state {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: $color-danger;
|
||||
|
||||
&.ok {
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.ls-none {
|
||||
color: $color-text-light;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
28
web/vite.config.ts
Normal file
28
web/vite.config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// The frontend is served by the Go binary at / (embedded dist), and talks to
|
||||
// the same origin via /api/manager/*.
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "@/styles/variables.scss" as *;`,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:17650',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user