mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Merge branch 'main' into opencode/glowing-orchid
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -112,6 +112,12 @@ jobs:
|
|||||||
upx --best --lzma gost-amd64
|
upx --best --lzma gost-amd64
|
||||||
upx --best --lzma gost-arm64
|
upx --best --lzma gost-arm64
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
|
working-directory: ./go-gost
|
||||||
|
run: |
|
||||||
|
sha256sum gost-amd64 > gost-amd64.sha256
|
||||||
|
sha256sum gost-arm64 > gost-arm64.sha256
|
||||||
|
|
||||||
- name: Upload GOST AMD64 artifact
|
- name: Upload GOST AMD64 artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -124,6 +130,18 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./go-gost/gost-arm64
|
path: ./go-gost/gost-arm64
|
||||||
|
|
||||||
|
- name: Upload GOST AMD64 checksum artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-amd64
|
||||||
|
path: ./go-gost/gost-amd64.sha256
|
||||||
|
|
||||||
|
- name: Upload GOST ARM64 checksum artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-arm64
|
||||||
|
path: ./go-gost/gost-arm64.sha256
|
||||||
|
|
||||||
build-vite:
|
build-vite:
|
||||||
name: Build & Push Vite Frontend
|
name: Build & Push Vite Frontend
|
||||||
needs: check-version
|
needs: check-version
|
||||||
@@ -238,7 +256,20 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./artifacts/arm64
|
path: ./artifacts/arm64
|
||||||
|
|
||||||
|
- name: Download GOST AMD64 checksum
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-amd64
|
||||||
|
path: ./artifacts/
|
||||||
|
|
||||||
|
- name: Download GOST ARM64 checksum
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-arm64
|
||||||
|
path: ./artifacts/
|
||||||
|
|
||||||
- name: Prepare release files
|
- name: Prepare release files
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.check-version.outputs.version }}"
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||||
@@ -331,6 +362,10 @@ jobs:
|
|||||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||||
|
|
||||||
|
echo "📤 上传 GOST 校验文件..."
|
||||||
|
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||||
|
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||||
|
|
||||||
echo "📤 上传安装脚本..."
|
echo "📤 上传安装脚本..."
|
||||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||||
@@ -363,6 +398,18 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./artifacts/arm64
|
path: ./artifacts/arm64
|
||||||
|
|
||||||
|
- name: Download GOST AMD64 checksum
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-amd64
|
||||||
|
path: ./artifacts/
|
||||||
|
|
||||||
|
- name: Download GOST ARM64 checksum
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gost-checksum-arm64
|
||||||
|
path: ./artifacts/
|
||||||
|
|
||||||
- name: Rename binaries
|
- name: Rename binaries
|
||||||
run: |
|
run: |
|
||||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||||
@@ -379,4 +426,9 @@ jobs:
|
|||||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||||
|
|
||||||
|
echo "📤 上传 GOST 校验文件..."
|
||||||
|
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||||
|
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||||
|
|
||||||
echo "✅ GOST 二进制文件更新完成"
|
echo "✅ GOST 二进制文件更新完成"
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ build/
|
|||||||
*.dylib
|
*.dylib
|
||||||
your_app.exe
|
your_app.exe
|
||||||
go-backend/paneld
|
go-backend/paneld
|
||||||
|
go-gost/gost
|
||||||
|
|
||||||
# Go 测试二进制文件
|
# Go 测试二进制文件
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -2,35 +2,7 @@
|
|||||||
|
|
||||||
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||||
|
|
||||||
## Original Project
|
|
||||||
- **Name**: flux-panel
|
|
||||||
- **Source**: https://github.com/bqlpfy/flux-panel
|
|
||||||
- **License**: Apache License 2.0
|
|
||||||
|
|
||||||
## Modifications
|
|
||||||
The following major changes and additions have been made in this fork (FLVX):
|
|
||||||
|
|
||||||
### 1. Backend Architecture (Replaced)
|
|
||||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
|
||||||
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
|
||||||
|
|
||||||
### 2. Forwarding Agent (Modified)
|
|
||||||
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
|
||||||
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
|
||||||
|
|
||||||
### 3. Frontend (Modified)
|
|
||||||
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
|
||||||
|
|
||||||
### 4. Mobile Applications (Removed)
|
|
||||||
- **Removed**: `android-app/` - Source code for the Android client.
|
|
||||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
|
||||||
|
|
||||||
### 5. Infrastructure & Scripts
|
|
||||||
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
|
||||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
|
||||||
- **Added**: `AGENTS.md` (Project documentation).
|
|
||||||
|
|
||||||
---
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
|
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
|
||||||
@@ -39,6 +11,10 @@ The following major changes and additions have been made in this fork (FLVX):
|
|||||||
- 可针对 **指定用户的指定隧道进行限速** 设置
|
- 可针对 **指定用户的指定隧道进行限速** 设置
|
||||||
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
|
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
|
||||||
- 提供灵活的转发策略配置,适用于多种网络场景
|
- 提供灵活的转发策略配置,适用于多种网络场景
|
||||||
|
- 面板分享,支持将节点分享给其他人,面板对接面板
|
||||||
|
- 支持分组权限管理,隧道分组、用户分组
|
||||||
|
- 支持批量功能,可以批量下发配置,启停等
|
||||||
|
- 支持隧道修改配置、转发修改隧道
|
||||||
|
|
||||||
|
|
||||||
## 部署流程
|
## 部署流程
|
||||||
@@ -137,6 +113,37 @@ docker compose -f docker-compose-v4.yml up -d
|
|||||||
|
|
||||||
> ⚠️ 首次登录后请立即修改默认密码!
|
> ⚠️ 首次登录后请立即修改默认密码!
|
||||||
|
|
||||||
|
---
|
||||||
|
## Original Project
|
||||||
|
- **Name**: flux-panel
|
||||||
|
- **Source**: https://github.com/bqlpfy/flux-panel
|
||||||
|
- **License**: Apache License 2.0
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
The following major changes and additions have been made in this fork (FLVX):
|
||||||
|
|
||||||
|
### 1. Backend Architecture (Replaced)
|
||||||
|
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
||||||
|
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
||||||
|
|
||||||
|
### 2. Forwarding Agent (Modified)
|
||||||
|
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
||||||
|
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
||||||
|
|
||||||
|
### 3. Frontend (Modified)
|
||||||
|
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
||||||
|
|
||||||
|
### 4. Mobile Applications (Removed)
|
||||||
|
- **Removed**: `android-app/` - Source code for the Android client.
|
||||||
|
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||||
|
|
||||||
|
### 5. Infrastructure & Scripts
|
||||||
|
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
||||||
|
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||||
|
- **Added**: `AGENTS.md` (Project documentation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
|||||||
安装完成后,服务会自动启动。
|
安装完成后,服务会自动启动。
|
||||||
- 查看状态: `systemctl status flux_agent`
|
- 查看状态: `systemctl status flux_agent`
|
||||||
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Caddy 反向代理(可选)
|
||||||
|
|
||||||
|
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
|
||||||
|
|
||||||
|
### 1. 安装 Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||||
|
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
|
||||||
|
|
||||||
|
### 2. 配置 Caddyfile
|
||||||
|
|
||||||
|
编辑 Caddy 配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 面板域名配置
|
||||||
|
|
||||||
|
将 `panel.example.com` 替换为你自己的域名:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
panel.example.com {
|
||||||
|
reverse_proxy localhost:6366
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
|
||||||
|
|
||||||
|
### 3. 重启 Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 注意事项
|
||||||
|
|
||||||
|
- 确保域名已正确解析到服务器 IP。
|
||||||
|
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
|
||||||
|
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
|
||||||
|
```
|
||||||
|
FRONTEND_PORT=127.0.0.1:6366
|
||||||
|
```
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ func NewFederationClient() *FederationClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
|
||||||
|
return &FederationClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
||||||
url = strings.TrimSuffix(url, "/")
|
url = strings.TrimSuffix(url, "/")
|
||||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
@@ -40,6 +41,17 @@ type resetPeerShareFlowRequest struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updatePeerShareRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
|
PortRangeStart int `json:"portRangeStart"`
|
||||||
|
PortRangeEnd int `json:"portRangeEnd"`
|
||||||
|
AllowedDomains string `json:"allowedDomains"`
|
||||||
|
AllowedIPs string `json:"allowedIps"`
|
||||||
|
}
|
||||||
|
|
||||||
type nodeImportRequest struct {
|
type nodeImportRequest struct {
|
||||||
RemoteURL string `json:"remoteUrl"`
|
RemoteURL string `json:"remoteUrl"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -325,6 +337,80 @@ func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Reques
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updatePeerShareRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := h.repo.GetPeerShare(req.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if share == nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Share not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxBandwidth < 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ExpiryTime < 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PortRangeStart > req.PortRangeEnd {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share.Name = req.Name
|
||||||
|
share.MaxBandwidth = req.MaxBandwidth
|
||||||
|
share.ExpiryTime = req.ExpiryTime
|
||||||
|
share.PortRangeStart = req.PortRangeStart
|
||||||
|
share.PortRangeEnd = req.PortRangeEnd
|
||||||
|
share.AllowedDomains = req.AllowedDomains
|
||||||
|
share.AllowedIPs = allowedIPs
|
||||||
|
share.UpdatedTime = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := h.repo.UpdatePeerShare(share); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
@@ -1315,6 +1401,74 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
|
||||||
|
type remoteEntry struct {
|
||||||
|
index int
|
||||||
|
remoteURL string
|
||||||
|
remoteToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
var remotes []remoteEntry
|
||||||
|
for i, item := range items {
|
||||||
|
isRemote, _ := item["isRemote"].(int)
|
||||||
|
if isRemote != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, _ := item["remoteUrl"].(string)
|
||||||
|
token, _ := item["remoteToken"].(string)
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if url == "" || token == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token})
|
||||||
|
}
|
||||||
|
if len(remotes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localDomain := h.federationLocalDomain()
|
||||||
|
fc := client.NewFederationClientWithTimeout(5 * time.Second)
|
||||||
|
|
||||||
|
type syncResult struct {
|
||||||
|
index int
|
||||||
|
status int
|
||||||
|
syncError string
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]syncResult, len(remotes))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, entry := range remotes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, e remoteEntry) {
|
||||||
|
defer wg.Done()
|
||||||
|
info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"}
|
||||||
|
} else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"}
|
||||||
|
} else if strings.Contains(errMsg, "Share expired") {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"}
|
||||||
|
} else {
|
||||||
|
results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""}
|
||||||
|
}
|
||||||
|
}(i, entry)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
items[r.index]["status"] = r.status
|
||||||
|
if r.syncError != "" {
|
||||||
|
items[r.index]["syncError"] = r.syncError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
||||||
if h == nil || h.repo == nil || shareID <= 0 {
|
if h == nil || h.repo == nil || shareID <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
|
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
||||||
|
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
|
||||||
|
mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade)
|
||||||
|
mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback)
|
||||||
|
mux.HandleFunc("/api/v1/node/releases", h.listReleases)
|
||||||
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
|
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
|
||||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||||
@@ -155,6 +159,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
||||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||||
|
mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate)
|
||||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||||
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
||||||
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
||||||
@@ -320,6 +325,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.syncRemoteNodeStatuses(items)
|
||||||
|
|
||||||
response.WriteJSON(w, response.OK(items))
|
response.WriteJSON(w, response.OK(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||||
response.WriteJSON(w, response.OK(cmd))
|
response.WriteJSON(w, response.OK(cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
githubRepo = "Sagit-chu/flvx"
|
||||||
|
githubProxy = "https://gcode.hostcentral.cc"
|
||||||
|
githubAPIBase = "https://api.github.com"
|
||||||
|
githubHTMLBase = "https://github.com"
|
||||||
|
upgradeTimeout = 5 * time.Minute
|
||||||
|
batchWorkers = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(req.Version)
|
||||||
|
if version == "" {
|
||||||
|
var err error
|
||||||
|
version, err = resolveLatestRelease()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := fmt.Sprintf(
|
||||||
|
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||||
|
githubHTMLBase, githubRepo, version,
|
||||||
|
)
|
||||||
|
checksumURL := fmt.Sprintf(
|
||||||
|
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||||
|
githubHTMLBase, githubRepo, version,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
||||||
|
"downloadUrl": downloadURL,
|
||||||
|
"checksumUrl": checksumURL,
|
||||||
|
}, upgradeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"version": version,
|
||||||
|
"message": result.Message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLatestRelease() (string, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("请求GitHub失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
|
||||||
|
return resolveLatestReleaseAPI()
|
||||||
|
}
|
||||||
|
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
if location == "" {
|
||||||
|
return resolveLatestReleaseAPI()
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(location, "/")
|
||||||
|
tag := parts[len(parts)-1]
|
||||||
|
if tag == "" || tag == "latest" {
|
||||||
|
return resolveLatestReleaseAPI()
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLatestReleaseAPI() (string, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("请求GitHub API失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||||
|
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(release.TagName) == "" {
|
||||||
|
return "", fmt.Errorf("无法从GitHub获取最新版本号")
|
||||||
|
}
|
||||||
|
|
||||||
|
return release.TagName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(req.Version)
|
||||||
|
if version == "" {
|
||||||
|
var err error
|
||||||
|
version, err = resolveLatestRelease()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := fmt.Sprintf(
|
||||||
|
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||||
|
githubHTMLBase, githubRepo, version,
|
||||||
|
)
|
||||||
|
checksumURL := fmt.Sprintf(
|
||||||
|
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||||
|
githubHTMLBase, githubRepo, version,
|
||||||
|
)
|
||||||
|
|
||||||
|
type upgradeResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]upgradeResult, len(req.IDs))
|
||||||
|
sem := make(chan struct{}, batchWorkers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, id := range req.IDs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int, nodeID int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{
|
||||||
|
"downloadUrl": downloadURL,
|
||||||
|
"checksumUrl": checksumURL,
|
||||||
|
}, upgradeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
||||||
|
}(i, id)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"version": version,
|
||||||
|
"results": results,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var releases []struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseItem struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublishedAt string `json:"publishedAt"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]releaseItem, 0, len(releases))
|
||||||
|
for _, r := range releases {
|
||||||
|
if r.Draft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, releaseItem{
|
||||||
|
Version: r.TagName,
|
||||||
|
Name: r.Name,
|
||||||
|
PublishedAt: r.PublishedAt,
|
||||||
|
Prerelease: r.Prerelease,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"message": result.Message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -168,7 +168,13 @@ func Open(path string) (*Repository, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := sql.Open("sqlite", path)
|
// Use _pragma DSN parameters so every connection from the pool gets
|
||||||
|
// the same settings (busy_timeout and synchronous are per-connection).
|
||||||
|
dsn := "file:" + path +
|
||||||
|
"?_pragma=busy_timeout(5000)" +
|
||||||
|
"&_pragma=journal_mode(WAL)" +
|
||||||
|
"&_pragma=synchronous(NORMAL)"
|
||||||
|
raw, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ type pendingRequest struct {
|
|||||||
ch chan CommandResult
|
ch chan CommandResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsPingPeriod = 15 * time.Second
|
||||||
|
wsPongWait = 45 * time.Second
|
||||||
|
wsWriteWait = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type CommandResult struct {
|
type CommandResult struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cw := &connWrap{conn: conn}
|
cw := &connWrap{conn: conn}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go startKeepalive(cw, done)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.admins[cw] = struct{}{}
|
s.admins[cw] = struct{}{}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
close(done)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.admins, cw)
|
delete(s.admins, cw)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cw := &connWrap{conn: conn}
|
cw := &connWrap{conn: conn}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go startKeepalive(cw, done)
|
||||||
|
|
||||||
version := r.URL.Query().Get("version")
|
version := r.URL.Query().Get("version")
|
||||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
||||||
@@ -165,6 +184,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
|||||||
s.broadcastStatus(nodeID, 1)
|
s.broadcastStatus(nodeID, 1)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
close(done)
|
||||||
needOfflineBroadcast := false
|
needOfflineBroadcast := false
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
current, ok := s.nodes[nodeID]
|
current, ok := s.nodes[nodeID]
|
||||||
@@ -190,7 +210,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
|||||||
|
|
||||||
msg := decryptIfNeeded(payload, secret)
|
msg := decryptIfNeeded(payload, secret)
|
||||||
s.tryResolvePending(nodeID, msg)
|
s.tryResolvePending(nodeID, msg)
|
||||||
s.broadcastInfo(nodeID, msg)
|
|
||||||
|
var parsed struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
|
||||||
|
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||||
|
} else {
|
||||||
|
s.broadcastInfo(nodeID, msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +292,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
ns.conn.mu.Lock()
|
ns.conn.mu.Lock()
|
||||||
|
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||||
|
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
|
||||||
ns.conn.mu.Unlock()
|
ns.conn.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) {
|
|||||||
s.broadcastToAdmins(string(raw))
|
s.broadcastToAdmins(string(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
|
||||||
|
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
s.broadcastToAdmins(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) broadcastToAdmins(message string) {
|
func (s *Server) broadcastToAdmins(message string) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
admins := make([]*connWrap, 0, len(s.admins))
|
admins := make([]*connWrap, 0, len(s.admins))
|
||||||
@@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) {
|
|||||||
|
|
||||||
for _, c := range admins {
|
for _, c := range admins {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("websocket broadcast failed: %v", err)
|
log.Printf("websocket broadcast failed: %v", err)
|
||||||
@@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
||||||
|
if cw == nil || cw.conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(wsPingPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cw.mu.Lock()
|
||||||
|
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
|
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
_ = cw.conn.SetWriteDeadline(time.Time{})
|
||||||
|
cw.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
_ = cw.conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync" // 新增:用于管理连接状态的互斥锁
|
"sync" // 新增:用于管理连接状态的互斥锁
|
||||||
@@ -21,7 +28,6 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v3/host"
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
"github.com/shirou/gopsutil/v3/mem"
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
psnet "github.com/shirou/gopsutil/v3/net"
|
psnet "github.com/shirou/gopsutil/v3/net"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemInfo 系统信息结构体
|
// SystemInfo 系统信息结构体
|
||||||
@@ -85,6 +91,11 @@ type TcpPingResponse struct {
|
|||||||
RequestId string `json:"requestId,omitempty"`
|
RequestId string `json:"requestId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
reporterReadWait = 60 * time.Second
|
||||||
|
reporterWriteWait = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type WebSocketReporter struct {
|
type WebSocketReporter struct {
|
||||||
url string
|
url string
|
||||||
addr string // 保存服务器地址
|
addr string // 保存服务器地址
|
||||||
@@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error {
|
|||||||
|
|
||||||
w.conn = conn
|
w.conn = conn
|
||||||
w.connected = true
|
w.connected = true
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||||
|
conn.SetPingHandler(func(appData string) error {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||||
|
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait))
|
||||||
|
})
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||||
|
})
|
||||||
|
|
||||||
// 设置关闭处理器来检测连接状态
|
// 设置关闭处理器来检测连接状态
|
||||||
w.conn.SetCloseHandler(func(code int, text string) error {
|
w.conn.SetCloseHandler(func(code int, text string) error {
|
||||||
@@ -377,7 +396,7 @@ func (w *WebSocketReporter) receiveMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置读取超时
|
// 设置读取超时
|
||||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||||
|
|
||||||
messageType, message, err := conn.ReadMessage()
|
messageType, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmdMsg.Type != "call" {
|
if cmdMsg.Type != "call" {
|
||||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
|
||||||
// 其他状态变更命令保持同步,确保顺序执行
|
// 其他状态变更命令保持同步,确保顺序执行
|
||||||
if cmdMsg.Type == "TcpPing" {
|
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||||
go w.routeCommand(cmdMsg)
|
go w.routeCommand(cmdMsg)
|
||||||
} else {
|
} else {
|
||||||
w.routeCommand(cmdMsg)
|
w.routeCommand(cmdMsg)
|
||||||
@@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmdMsg.Type != "call" {
|
if cmdMsg.Type != "call" {
|
||||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
|
||||||
// 其他状态变更命令保持同步,确保顺序执行
|
// 其他状态变更命令保持同步,确保顺序执行
|
||||||
if cmdMsg.Type == "TcpPing" {
|
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||||
go w.routeCommand(cmdMsg)
|
go w.routeCommand(cmdMsg)
|
||||||
} else {
|
} else {
|
||||||
w.routeCommand(cmdMsg)
|
w.routeCommand(cmdMsg)
|
||||||
@@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
|||||||
response.Type = "SetProtocolResponse"
|
response.Type = "SetProtocolResponse"
|
||||||
needSaveConfig = true
|
needSaveConfig = true
|
||||||
|
|
||||||
|
// 升级 Agent 命令(异步执行,不需要保存配置)
|
||||||
|
case "UpgradeAgent":
|
||||||
|
err = w.handleUpgradeAgent(cmd.Data)
|
||||||
|
response.Type = "UpgradeAgentResponse"
|
||||||
|
// needSaveConfig = false (默认值)
|
||||||
|
|
||||||
|
// 回退 Agent 到旧版本
|
||||||
|
case "RollbackAgent":
|
||||||
|
err = w.handleRollbackAgent(cmd.Data)
|
||||||
|
response.Type = "RollbackAgentResponse"
|
||||||
|
// needSaveConfig = false (默认值)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||||
response.Type = "UnknownCommandResponse"
|
response.Type = "UnknownCommandResponse"
|
||||||
@@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendUpgradeProgress 通过 WS 发送升级进度消息
|
||||||
|
func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) {
|
||||||
|
response := CommandResponse{
|
||||||
|
Type: "UpgradeProgress",
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"stage": stage,
|
||||||
|
"percent": percent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.sendResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
ChecksumURL string `json:"checksumUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||||
|
return fmt.Errorf("解析升级参数失败: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.DownloadURL) == "" {
|
||||||
|
return fmt.Errorf("下载地址不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换架构占位符
|
||||||
|
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
||||||
|
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
||||||
|
|
||||||
|
w.sendUpgradeProgress("downloading", 0, "开始下载升级包...")
|
||||||
|
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
||||||
|
|
||||||
|
// 下载新版本二进制
|
||||||
|
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||||
|
tmpPath := binaryPath + ".new"
|
||||||
|
backupPath := binaryPath + ".old"
|
||||||
|
|
||||||
|
resp, err := http.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载升级包失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带进度的下载
|
||||||
|
totalSize := resp.ContentLength
|
||||||
|
var downloaded int64
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
lastPercent := 0
|
||||||
|
hasher := sha256.New()
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, readErr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
||||||
|
outFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("写入升级包失败: %v", wErr)
|
||||||
|
}
|
||||||
|
hasher.Write(buf[:n])
|
||||||
|
downloaded += int64(n)
|
||||||
|
if totalSize > 0 {
|
||||||
|
percent := int(downloaded * 100 / totalSize)
|
||||||
|
if percent-lastPercent >= 10 {
|
||||||
|
lastPercent = percent
|
||||||
|
w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("读取升级包失败: %v", readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
|
if downloaded == 0 {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("下载的升级包为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
||||||
|
|
||||||
|
// Checksum 校验
|
||||||
|
if checksumURL != "" {
|
||||||
|
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||||
|
checksumResp, err := http.Get(checksumURL)
|
||||||
|
if err == nil {
|
||||||
|
defer checksumResp.Body.Close()
|
||||||
|
if checksumResp.StatusCode == http.StatusOK {
|
||||||
|
checksumBody, err := io.ReadAll(checksumResp.Body)
|
||||||
|
if err == nil {
|
||||||
|
// 格式: "<hash> <filename>" 或 "<hash>"
|
||||||
|
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
|
||||||
|
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
if !strings.EqualFold(expectedHash, actualHash) {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("设置执行权限失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份旧版本
|
||||||
|
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||||
|
if _, err := os.Stat(binaryPath); err == nil {
|
||||||
|
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
|
||||||
|
oldData, err := os.ReadFile(binaryPath)
|
||||||
|
if err == nil {
|
||||||
|
_ = os.WriteFile(backupPath, oldData, 0755)
|
||||||
|
fmt.Println("📦 旧版本已备份到", backupPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
||||||
|
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
|
||||||
|
|
||||||
|
// 执行重启脚本
|
||||||
|
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
||||||
|
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
||||||
|
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
|
||||||
|
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("启动重启脚本失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendUpgradeProgress("installing", 100, "重启中...")
|
||||||
|
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||||
|
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||||
|
backupPath := binaryPath + ".old"
|
||||||
|
|
||||||
|
// 检查备份文件是否存在
|
||||||
|
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("没有可用的备份文件,无法回退")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🔄 开始回退到旧版本...")
|
||||||
|
|
||||||
|
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
|
||||||
|
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
|
||||||
|
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("启动回退脚本失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||||
path := "config.json"
|
path := "config.json"
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -94,4 +96,4 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export const checkNodeStatus = (nodeId?: number) => {
|
|||||||
return Network.post("/node/check-status", params);
|
return Network.post("/node/check-status", params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const upgradeNode = (id: number, version?: string) =>
|
||||||
|
Network.post("/node/upgrade", { id, version: version || "" }, { timeout: 5 * 60 * 1000 });
|
||||||
|
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||||
|
Network.post("/node/batch-upgrade", { ids, version: version || "" }, { timeout: 15 * 60 * 1000 });
|
||||||
|
export const getNodeReleases = () => Network.post("/node/releases");
|
||||||
|
export const rollbackNode = (id: number) =>
|
||||||
|
Network.post("/node/rollback", { id });
|
||||||
|
|
||||||
// 隧道CRUD操作 - 全部使用POST请求
|
// 隧道CRUD操作 - 全部使用POST请求
|
||||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||||
export const getTunnelList = () => Network.post("/tunnel/list");
|
export const getTunnelList = () => Network.post("/tunnel/list");
|
||||||
@@ -200,6 +208,16 @@ export const createPeerShare = (data: {
|
|||||||
allowedDomains?: string;
|
allowedDomains?: string;
|
||||||
allowedIps?: string;
|
allowedIps?: string;
|
||||||
}) => Network.post("/federation/share/create", data);
|
}) => Network.post("/federation/share/create", data);
|
||||||
|
export const updatePeerShare = (data: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maxBandwidth: number;
|
||||||
|
expiryTime: number;
|
||||||
|
portRangeStart: number;
|
||||||
|
portRangeEnd: number;
|
||||||
|
allowedDomains: string;
|
||||||
|
allowedIps: string;
|
||||||
|
}) => Network.post("/federation/share/update", data);
|
||||||
export const deletePeerShare = (id: number) =>
|
export const deletePeerShare = (id: number) =>
|
||||||
Network.post("/federation/share/delete", { id });
|
Network.post("/federation/share/delete", { id });
|
||||||
export const resetPeerShareFlow = (id: number) =>
|
export const resetPeerShareFlow = (id: number) =>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface ApiResponse<T = any> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 处理token失效的逻辑
|
// 处理token失效的逻辑
|
||||||
function handleTokenExpired() {
|
function handleTokenExpired() {
|
||||||
// 清除localStorage中的token
|
// 清除localStorage中的token
|
||||||
@@ -71,6 +75,7 @@ const Network = {
|
|||||||
get: function <T = any>(
|
get: function <T = any>(
|
||||||
path: string = "",
|
path: string = "",
|
||||||
data: any = {},
|
data: any = {},
|
||||||
|
options: RequestOptions = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||||
@@ -83,7 +88,7 @@ const Network = {
|
|||||||
axios
|
axios
|
||||||
.get(path, {
|
.get(path, {
|
||||||
params: data,
|
params: data,
|
||||||
timeout: 30000,
|
timeout: options.timeout ?? 30000,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: window.localStorage.getItem("token"),
|
Authorization: window.localStorage.getItem("token"),
|
||||||
},
|
},
|
||||||
@@ -117,6 +122,7 @@ const Network = {
|
|||||||
post: function <T = any>(
|
post: function <T = any>(
|
||||||
path: string = "",
|
path: string = "",
|
||||||
data: any = {},
|
data: any = {},
|
||||||
|
options: RequestOptions = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||||
@@ -128,7 +134,7 @@ const Network = {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.post(path, data, {
|
.post(path, data, {
|
||||||
timeout: 30000,
|
timeout: options.timeout ?? 30000,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: window.localStorage.getItem("token"),
|
Authorization: window.localStorage.getItem("token"),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import { Progress } from "@heroui/progress";
|
import { Progress } from "@heroui/progress";
|
||||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||||
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
import { Checkbox } from "@heroui/checkbox";
|
import { Checkbox } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -45,6 +46,10 @@ import {
|
|||||||
getNodeInstallCommand,
|
getNodeInstallCommand,
|
||||||
updateNodeOrder,
|
updateNodeOrder,
|
||||||
batchDeleteNodes,
|
batchDeleteNodes,
|
||||||
|
upgradeNode,
|
||||||
|
batchUpgradeNodes,
|
||||||
|
getNodeReleases,
|
||||||
|
rollbackNode,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -65,6 +70,7 @@ interface Node {
|
|||||||
status: number;
|
status: number;
|
||||||
isRemote?: number;
|
isRemote?: number;
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
|
syncError?: string;
|
||||||
connectionStatus: "online" | "offline";
|
connectionStatus: "online" | "offline";
|
||||||
systemInfo?: {
|
systemInfo?: {
|
||||||
cpuUsage: number;
|
cpuUsage: number;
|
||||||
@@ -76,6 +82,8 @@ interface Node {
|
|||||||
uptime: number;
|
uptime: number;
|
||||||
} | null;
|
} | null;
|
||||||
copyLoading?: boolean;
|
copyLoading?: boolean;
|
||||||
|
upgradeLoading?: boolean;
|
||||||
|
rollbackLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeForm {
|
interface NodeForm {
|
||||||
@@ -117,7 +125,7 @@ const SortableItem = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes}>
|
<div ref={setNodeRef} style={style} {...attributes} className="overflow-hidden">
|
||||||
{children(listeners)}
|
{children(listeners)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -164,6 +172,16 @@ export default function NodePage() {
|
|||||||
const [installCommand, setInstallCommand] = useState("");
|
const [installCommand, setInstallCommand] = useState("");
|
||||||
const [currentNodeName, setCurrentNodeName] = useState("");
|
const [currentNodeName, setCurrentNodeName] = useState("");
|
||||||
|
|
||||||
|
// 升级相关状态
|
||||||
|
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||||
|
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single");
|
||||||
|
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(null);
|
||||||
|
const [releases, setReleases] = useState<Array<{ version: string; name: string; publishedAt: string; prerelease: boolean }>>([]);
|
||||||
|
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState("");
|
||||||
|
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||||
|
const [upgradeProgress, setUpgradeProgress] = useState<Record<number, { stage: string; percent: number; message: string }>>({});
|
||||||
|
|
||||||
const websocketRef = useRef<WebSocket | null>(null);
|
const websocketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
@@ -219,7 +237,8 @@ export default function NodePage() {
|
|||||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
inx: node.inx ?? 0,
|
inx: node.inx ?? 0,
|
||||||
connectionStatus: node.status === 1 ? "online" : "offline",
|
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
|
||||||
|
syncError: node.syncError || undefined,
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false,
|
copyLoading: false,
|
||||||
}));
|
}));
|
||||||
@@ -423,6 +442,22 @@ export default function NodePage() {
|
|||||||
return node;
|
return node;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (type === "upgrade_progress") {
|
||||||
|
try {
|
||||||
|
const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData;
|
||||||
|
if (progressData?.data) {
|
||||||
|
setUpgradeProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeId]: {
|
||||||
|
stage: progressData.data.stage || "",
|
||||||
|
percent: progressData.data.percent || 0,
|
||||||
|
message: progressData.message || "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -768,6 +803,93 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 打开版本选择弹窗
|
||||||
|
const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => {
|
||||||
|
setUpgradeTarget(target);
|
||||||
|
setUpgradeTargetNodeId(nodeId || null);
|
||||||
|
setSelectedVersion("");
|
||||||
|
setUpgradeModalOpen(true);
|
||||||
|
setReleasesLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getNodeReleases();
|
||||||
|
if (res.code === 0 && Array.isArray(res.data)) {
|
||||||
|
setReleases(res.data);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "获取版本列表失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("获取版本列表失败");
|
||||||
|
} finally {
|
||||||
|
setReleasesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认升级(从版本弹窗)
|
||||||
|
const handleConfirmUpgrade = async () => {
|
||||||
|
const version = selectedVersion || undefined;
|
||||||
|
if (upgradeTarget === "single" && upgradeTargetNodeId) {
|
||||||
|
setUpgradeModalOpen(false);
|
||||||
|
// Find the node
|
||||||
|
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
|
||||||
|
if (!node) return;
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const res = await upgradeNode(upgradeTargetNodeId, version);
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "升级失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (upgradeTarget === "batch") {
|
||||||
|
setBatchUpgradeLoading(true);
|
||||||
|
setUpgradeModalOpen(false);
|
||||||
|
try {
|
||||||
|
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "批量升级失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setBatchUpgradeLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回退节点
|
||||||
|
const handleRollbackNode = async (node: Node) => {
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const res = await rollbackNode(node.id);
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "回退失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -1046,6 +1168,15 @@ export default function NodePage() {
|
|||||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||||
清空
|
清空
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
isLoading={batchUpgradeLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openUpgradeModal("batch")}
|
||||||
|
>
|
||||||
|
升级
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1122,7 +1253,7 @@ export default function NodePage() {
|
|||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
@@ -1181,6 +1312,17 @@ export default function NodePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3">
|
<CardBody className="pt-0 pb-3">
|
||||||
|
{isRemoteNode && node.syncError && (
|
||||||
|
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||||
|
{node.syncError === "provider_share_deleted"
|
||||||
|
? "提供方已删除该分享"
|
||||||
|
: node.syncError === "provider_share_disabled"
|
||||||
|
? "提供方已禁用该分享"
|
||||||
|
: node.syncError === "provider_share_expired"
|
||||||
|
? "提供方分享已过期"
|
||||||
|
: `远程同步失败: ${node.syncError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
@@ -1226,6 +1368,18 @@ export default function NodePage() {
|
|||||||
{node.version || "未知"}
|
{node.version || "未知"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Progress
|
||||||
|
aria-label="升级进度"
|
||||||
|
color="warning"
|
||||||
|
label={upgradeProgress[node.id].message}
|
||||||
|
showValueLabel
|
||||||
|
size="sm"
|
||||||
|
value={upgradeProgress[node.id].percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-default-600">开机时间</span>
|
<span className="text-default-600">开机时间</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
@@ -1360,32 +1514,56 @@ export default function NodePage() {
|
|||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex gap-1.5">
|
{!isRemoteNode && (
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="success"
|
||||||
|
isLoading={node.copyLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleCopyInstallCommand(node)}
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="warning"
|
||||||
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
|
isLoading={node.upgradeLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openUpgradeModal("single", node.id)}
|
||||||
|
>
|
||||||
|
升级
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="secondary"
|
||||||
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
|
isLoading={node.rollbackLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleRollbackNode(node)}
|
||||||
|
>
|
||||||
|
回退
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}>
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
className="min-h-8"
|
||||||
className="flex-1 min-h-8"
|
color="primary"
|
||||||
color="success"
|
size="sm"
|
||||||
isLoading={node.copyLoading}
|
variant="flat"
|
||||||
size="sm"
|
onPress={() => handleEdit(node)}
|
||||||
variant="flat"
|
>
|
||||||
onPress={() => handleCopyInstallCommand(node)}
|
编辑
|
||||||
>
|
</Button>
|
||||||
安装
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 min-h-8"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleEdit(node)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
className="min-h-8"
|
||||||
color="danger"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1831,6 +2009,84 @@ export default function NodePage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 版本选择升级模态框 */}
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
isOpen={upgradeModalOpen}
|
||||||
|
placement="center"
|
||||||
|
scrollBehavior="outside"
|
||||||
|
size="md"
|
||||||
|
onOpenChange={setUpgradeModalOpen}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{upgradeTarget === "batch"
|
||||||
|
? `批量升级 (${selectedIds.size} 个节点)`
|
||||||
|
: "升级节点"}
|
||||||
|
</h2>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
{releasesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="选择版本"
|
||||||
|
placeholder="留空则使用最新版本"
|
||||||
|
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as string;
|
||||||
|
setSelectedVersion(selected || "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{releases.map((r) => (
|
||||||
|
<SelectItem key={r.version} textValue={r.version}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>{r.version}</span>
|
||||||
|
<span className="text-xs text-default-400">
|
||||||
|
{r.publishedAt
|
||||||
|
? new Date(r.publishedAt).toLocaleDateString()
|
||||||
|
: ""}
|
||||||
|
{r.prerelease && (
|
||||||
|
<Chip className="ml-1" color="warning" size="sm" variant="flat">
|
||||||
|
预览
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
{selectedVersion
|
||||||
|
? `将升级到版本 ${selectedVersion}`
|
||||||
|
: "未选择版本,将自动使用最新稳定版"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
isDisabled={releasesLoading}
|
||||||
|
onPress={handleConfirmUpgrade}
|
||||||
|
>
|
||||||
|
确认升级
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 批量删除确认模态框 */}
|
{/* 批量删除确认模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resetPeerShareFlow,
|
resetPeerShareFlow,
|
||||||
getPeerRemoteUsageList,
|
getPeerRemoteUsageList,
|
||||||
importRemoteNode,
|
importRemoteNode,
|
||||||
|
updatePeerShare,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -77,6 +78,7 @@ interface RemoteUsageNode {
|
|||||||
usedPorts: number[];
|
usedPorts: number[];
|
||||||
bindings: RemoteUsageBinding[];
|
bindings: RemoteUsageBinding[];
|
||||||
activeBindingNum: number;
|
activeBindingNum: number;
|
||||||
|
syncError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PanelSharingPage() {
|
export default function PanelSharingPage() {
|
||||||
@@ -91,6 +93,7 @@ export default function PanelSharingPage() {
|
|||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [createShareOpen, setCreateShareOpen] = useState(false);
|
const [createShareOpen, setCreateShareOpen] = useState(false);
|
||||||
|
const [editShareOpen, setEditShareOpen] = useState(false);
|
||||||
const [importNodeOpen, setImportNodeOpen] = useState(false);
|
const [importNodeOpen, setImportNodeOpen] = useState(false);
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
@@ -110,6 +113,17 @@ export default function PanelSharingPage() {
|
|||||||
token: "",
|
token: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
maxBandwidth: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
portRangeStart: 10000,
|
||||||
|
portRangeEnd: 20000,
|
||||||
|
allowedDomains: "",
|
||||||
|
allowedIps: "",
|
||||||
|
});
|
||||||
|
|
||||||
const loadShares = useCallback(async () => {
|
const loadShares = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +253,52 @@ export default function PanelSharingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditShare = (share: PeerShare) => {
|
||||||
|
setEditForm({
|
||||||
|
id: share.id,
|
||||||
|
name: share.name,
|
||||||
|
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
|
||||||
|
expiryTime: share.expiryTime,
|
||||||
|
portRangeStart: share.portRangeStart,
|
||||||
|
portRangeEnd: share.portRangeEnd,
|
||||||
|
allowedDomains: share.allowedDomains || "",
|
||||||
|
allowedIps: share.allowedIps || "",
|
||||||
|
});
|
||||||
|
setEditShareOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditShare = async () => {
|
||||||
|
if (!editForm.name) {
|
||||||
|
toast.error("名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.maxBandwidth < 0) {
|
||||||
|
toast.error("流量上限不能为负数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await updatePeerShare({
|
||||||
|
id: editForm.id,
|
||||||
|
name: editForm.name,
|
||||||
|
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||||
|
expiryTime: editForm.expiryTime,
|
||||||
|
portRangeStart: editForm.portRangeStart,
|
||||||
|
portRangeEnd: editForm.portRangeEnd,
|
||||||
|
allowedDomains: editForm.allowedDomains,
|
||||||
|
allowedIps: editForm.allowedIps,
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success("编辑成功");
|
||||||
|
setEditShareOpen(false);
|
||||||
|
loadShares();
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "编辑失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("网络错误");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportNode = async () => {
|
const handleImportNode = async () => {
|
||||||
if (!importForm.remoteUrl || !importForm.token) {
|
if (!importForm.remoteUrl || !importForm.token) {
|
||||||
toast.error("请填写完整信息");
|
toast.error("请填写完整信息");
|
||||||
@@ -326,6 +386,13 @@ export default function PanelSharingPage() {
|
|||||||
<CardHeader className="flex justify-between">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openEditShare(share)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -390,6 +457,17 @@ export default function PanelSharingPage() {
|
|||||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
|
{node.syncError && (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||||
|
{node.syncError === "provider_share_deleted"
|
||||||
|
? "提供方已删除该分享"
|
||||||
|
: node.syncError === "provider_share_disabled"
|
||||||
|
? "提供方已禁用该分享"
|
||||||
|
: node.syncError === "provider_share_expired"
|
||||||
|
? "提供方分享已过期"
|
||||||
|
: `远程同步失败: ${node.syncError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||||
<p>共享ID: {node.shareId || "-"}</p>
|
<p>共享ID: {node.shareId || "-"}</p>
|
||||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||||
@@ -491,6 +569,67 @@ export default function PanelSharingPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Share Modal */}
|
||||||
|
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>编辑分享</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Input
|
||||||
|
label="名称"
|
||||||
|
placeholder="备注名称"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
label="起始端口"
|
||||||
|
type="number"
|
||||||
|
value={editForm.portRangeStart.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="结束端口"
|
||||||
|
type="number"
|
||||||
|
value={editForm.portRangeEnd.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="流量上限 (GB)"
|
||||||
|
type="number"
|
||||||
|
description="0 表示不限流量"
|
||||||
|
value={editForm.maxBandwidth.toString()}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="过期时间"
|
||||||
|
type="datetime-local"
|
||||||
|
description="留空或清除表示永久有效"
|
||||||
|
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="允许的域名 (可选)"
|
||||||
|
placeholder="example.com, panel.test.com"
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
|
value={editForm.allowedDomains}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="允许的API IP (可选)"
|
||||||
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
|
value={editForm.allowedIps}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||||
|
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Import Node Modal */}
|
{/* Import Node Modal */}
|
||||||
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
|
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user