Compare commits

..

4 Commits

Author SHA1 Message Date
sagit 6e25e1838d ci: migrate Docker images to GHCR and add tag-triggered release
- Replace Docker Hub with GitHub Container Registry (ghcr.io)
- Add tag push trigger for automatic release creation
- Dynamic version from tag name (2.0.8 format)
- Frontend GitHub links now use env variable VITE_GITHUB_REPO
- Release artifacts auto-replace repo/version in scripts
2026-01-24 04:40:12 +00:00
sagit c0c88a8466 update readme 2026-01-24 04:28:02 +00:00
sagit 4edbb66224 feat: 支持编辑隧道入口/出口/转发链配置
- 后端: TunnelUpdateDto 添加 inNodeId, chainNodes, outNodeId 字段
- 后端: TunnelServiceImpl 重写 updateTunnel() 支持节点配置更新
- 后端: 添加自动回滚机制,更新失败时恢复原配置
- 前端: tunnel.tsx 解除编辑模式下的字段禁用限制
- 前端: 保留隧道类型不可修改(端口转发/隧道转发)
2026-01-24 04:22:55 +00:00
sagit 84e01c7851 docs: add AGENTS.md knowledge base files 2026-01-24 03:52:58 +00:00
14 changed files with 756 additions and 201 deletions
+165 -135
View File
@@ -1,63 +1,74 @@
name: Build and Push Images Based on Version
name: Build and Push Images
# 在这里定义统一版本号
env:
VERSION: "2.0.7-beta"
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
REGISTRY: ghcr.io
on:
push:
branches:
- main
- beta
tags:
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
jobs:
check-version:
name: Check Version and Decide Build
runs-on: ubuntu-latest
outputs:
version: ${{ env.VERSION }}
should_build: ${{ steps.check-tag.outputs.should_build }}
should_build_gost: ${{ steps.check-tag.outputs.should_build_gost }}
tag_exists: ${{ steps.check-tag.outputs.tag_exists }}
version: ${{ steps.version.outputs.version }}
should_build: ${{ steps.version.outputs.should_build }}
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
is_tag: ${{ steps.version.outputs.is_tag }}
image_owner: ${{ steps.version.outputs.image_owner }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Display version
- name: Determine version and build strategy
id: version
run: |
echo "Current version: ${{ env.VERSION }}"
# 镜像 owner 需要小写
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
- name: Check if tag exists and detect changes
id: check-tag
run: |
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
echo "Tag ${{ env.VERSION }} already exists"
echo "tag_exists=true" >> $GITHUB_OUTPUT
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查从tag到现在,go-gost目录下的文件是否有变化
TAG_COMMIT=$(git rev-list -n 1 "${{ env.VERSION }}")
echo "Tag commit: $TAG_COMMIT"
echo "Current HEAD: $(git rev-parse HEAD)"
# 检查 go-gost 目录下是否有实际的文件内容变化
# 使用 --ignore-all-space 和 --ignore-blank-lines 来忽略空白差异
# 使用 --exit-code: 0表示无差异,1表示有差异
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
echo "✅ GOST files unchanged since tag (no content changes)"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
# 显示差异以便调试
echo "🔄 Detected changes in go-gost directory:"
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
else
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
# Tag 触发:直接使用 tag 名作为版本,全量构建
VERSION="${{ github.ref_name }}"
echo "🏷️ Tag trigger detected: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=true" >> $GITHUB_OUTPUT
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
echo "tag_exists=false" >> $GITHUB_OUTPUT
else
# 分支触发:使用 env.VERSION,检查是否需要构建
VERSION="${{ env.VERSION }}"
echo "🌿 Branch trigger detected, using version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=false" >> $GITHUB_OUTPUT
# 检查 tag 是否已存在
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists"
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查 go-gost 目录是否有变化
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
echo "✅ GOST files unchanged since tag"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
echo "🔄 Detected changes in go-gost directory"
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
else
echo "Tag $VERSION does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
fi
build-gost:
@@ -66,48 +77,42 @@ jobs:
if: needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install UPX
run: |
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
tar -xf upx-4.2.1-amd64_linux.tar.xz
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
rm -rf upx-4.2.1-amd64_linux*
- name: Build GOST binary (AMD64)
working-directory: ./go-gost
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
- name: Build GOST binary (ARM64)
working-directory: ./go-gost
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
- name: Compress with UPX (AMD64)
- name: Compress with UPX
working-directory: ./go-gost
run: |
upx --best --lzma gost-amd64
- name: Compress with UPX (ARM64)
working-directory: ./go-gost
run: |
upx --best --lzma gost-arm64
- name: Upload GOST AMD64 artifact
@@ -127,31 +132,36 @@ jobs:
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare build args
run: |
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./vite-frontend/.env.production
- name: Build and push Vite Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t bqlpfy/vite-frontend:latest \
-t bqlpfy/vite-frontend:${VERSION} \
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
./vite-frontend
build-java:
@@ -159,17 +169,20 @@ jobs:
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up JDK and Maven
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 21
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
@@ -180,33 +193,36 @@ jobs:
run: mvn clean package -DskipTests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Java Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t bqlpfy/springboot-backend:latest \
-t bqlpfy/springboot-backend:${VERSION} \
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
./springboot-backend
create-release:
name: Create Release and Tag
name: Create Release (Tag Only)
needs: [check-version, build-gost, build-vite, build-java]
if: needs.check-version.outputs.should_build == 'true'
if: needs.check-version.outputs.is_tag == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
@@ -220,60 +236,106 @@ jobs:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Rename binaries
- name: Prepare release files
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
REPO="${{ github.repository }}"
# 移动二进制文件
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
# 复制并修改 docker-compose 文件
cp docker-compose-v4.yml ./artifacts/docker-compose-v4.yml
cp docker-compose-v6.yml ./artifacts/docker-compose-v6.yml
# 替换镜像地址为 GHCR
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
# 复制并修改安装脚本
cp install.sh ./artifacts/install.sh
cp panel_install.sh ./artifacts/panel_install.sh
# 替换仓库地址和版本号
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/install.sh
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/panel_install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
# 创建并推送 tag
git tag "${VERSION}" ${{ github.sha }}
git push origin "${VERSION}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
# 获取 commit 信息
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
# 创建 release
gh release create "${VERSION}" \
--title "Release ${VERSION}" \
--notes "## 📝 Commit Information
- **Message**: ${COMMIT_MSG}
--notes "## 📝 Release Information
- **Version**: ${VERSION}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})" \
- **Message**: ${COMMIT_MSG}
## 📦 Docker Images
\`\`\`bash
# Backend
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}
# Frontend
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
\`\`\`
## 🚀 Quick Install
**Panel:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
\`\`\`
**Node:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`" \
--repo ${{ github.repository }}
# 上传所有文件到 release
echo "📤 上传 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传安装脚本..."
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
echo "📤 上传 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 所有文件已上传到 Release ${VERSION}"
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
echo "✅ Release ${VERSION} 创建完成"
update-release-gost:
name: Update GOST Binaries in Release
needs: [check-version, build-gost]
if: needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
@@ -297,42 +359,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "✅ GOST 二进制文件更新完成!"
echo "ℹ️ Docker 镜像未重新构建"
update-release-files:
name: Update Release Files Only
needs: check-version
if: needs.check-version.outputs.tag_exists == 'true' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'false'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Update Release Files
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "📤 更新 Release ${VERSION} 中的脚本和配置文件..."
# 上传安装脚本(覆盖)
echo "📤 更新安装脚本..."
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
# 上传 Docker Compose 配置文件(覆盖)
echo "📤 更新 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 文件更新完成!Release ${VERSION} 中的脚本和配置已更新"
echo "ℹ️ Docker 镜像和 GOST 二进制未重新构建"
echo "✅ GOST 二进制文件更新完成"
@@ -0,0 +1,71 @@
# Plan: 搭建开发环境
## 目标
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
## 当前状态
### ✅ 已安装
| 工具 | 版本 | 用途 |
|------|------|------|
| Node.js | v20.19.2 | vite-frontend |
| npm | 9.2.0 | vite-frontend |
| Go | 1.24.4 | go-gost |
| Docker | 29.1.4 | 容器化部署 |
### ❌ 缺失
| 工具 | 需求版本 | 用途 |
|------|----------|------|
| Java | 21 | springboot-backend |
| Maven | 3.x | 构建后端 |
| Docker Compose | v2 | 容器编排 |
---
## 执行任务
### Task 1: 安装 Java 21
```bash
apt-get update && apt-get install -y openjdk-21-jdk
```
**验证**: `java -version` 应显示 openjdk 21
### Task 2: 安装 Maven
```bash
apt-get install -y maven
```
**验证**: `mvn -v` 应显示 Maven 3.x
### Task 3: 安装 Docker Compose Plugin
```bash
apt-get install -y docker-compose-plugin
```
**验证**: `docker compose version` 应显示版本号
### Task 4: 安装前端依赖
```bash
cd /root/flux-panel/vite-frontend && npm install
```
**验证**: `node_modules/` 目录存在
### Task 5: 验证后端可构建
```bash
cd /root/flux-panel/springboot-backend && mvn clean compile -q
```
**验证**: 编译成功无错误
### Task 6: 验证 Go 模块
```bash
cd /root/flux-panel/go-gost && go mod download
```
**验证**: 依赖下载成功
---
## 完成标准
- [ ] `java -version` → openjdk 21
- [ ] `mvn -v` → Maven 3.x
- [ ] `docker compose version` → v2.x
- [ ] 前端: `npm run dev` 可启动
- [ ] 后端: `mvn compile` 成功
- [ ] Go: `go build .` 成功
+43
View File
@@ -0,0 +1,43 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Jan 24 2026
**Context:** Monorepo for Flux Panel (Traffic Forwarding)
## OVERVIEW
Flux Panel is a traffic forwarding management system based on [go-gost](https://github.com/go-gost/gost). It manages tunnels, port forwarding, and user quotas.
**Stack:** Monorepo (Java/Spring Boot Backend + React/Vite Frontend + Go/GOST Service).
## STRUCTURE
```
/root/flux-panel/
├── springboot-backend/ # Java 21 + Spring Boot 2.7 Admin API
├── vite-frontend/ # React 18 + Vite + HeroUI/NextUI
├── go-gost/ # Go 1.23 + GOST Extensions (Core logic)
├── docker-compose*.yml # Deployment configs (v4/v6)
└── *.sh # Install scripts (panel_install.sh, install.sh)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Admin API** | `springboot-backend/` | Users, quotas, billing logic |
| **UI/Dashboard** | `vite-frontend/` | Management console |
| **Core Forwarding** | `go-gost/` | GOST implementation & extensions |
| **Deploy** | `docker-compose-v4.yml` | Container orchestration |
## CONVENTIONS
- **Monorepo**: 3 distinct languages/stacks. Treat each subdir as a separate project.
- **Docker**: Primary deployment method.
- **Scripts**: `panel_install.sh` for panel, `install.sh` for nodes.
## COMMANDS
```bash
# Quick Deploy (Panel)
./panel_install.sh
# Quick Deploy (Node)
./install.sh
# Docker
docker-compose -f docker-compose-v4.yml up -d
```
+5 -28
View File
@@ -1,17 +1,5 @@
# flux-panel转发面板 哆啦A梦转发面板
# 赞助商
<p align="center">
<a href="https://vps.town" style="margin: 0 20px; text-align:center;">
<img src="./doc/vpstown.png" width="300">
</a>
<a href="https://whmcs.as211392.com" style="margin: 0 20px; text-align:center;">
<img src="./doc/as211392.png" width="300">
</a>
</p>
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
## 特性
@@ -30,21 +18,21 @@
#### 快速部署
面板端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
面板端(开发版):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(开发版):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
@@ -78,15 +66,4 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/beta/inst
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
[![Star History Chart](https://api.star-history.com/svg?repos=bqlpfy/flux-panel&type=Date)](https://www.star-history.com/#bqlpfy/flux-panel&Date)
请务必在合法、合规、安全的前提下使用本项目。
+31
View File
@@ -0,0 +1,31 @@
# GO-GOST SERVICE KNOWLEDGE BASE
## OVERVIEW
Core forwarding service based on GOST v3.
**Stack:** Go 1.23, GOST Core v0.3.1, GOST x (Extensions).
## STRUCTURE
```
go-gost/
├── main.go # Entry point
├── x/ # Local extensions (REPLACES github.com/go-gost/x)
│ ├── api/ # Management API
│ ├── registry/ # Service registry
│ ├── handler/ # Protocol handlers (socks, tunnel, relay)
│ └── listener/ # Network listeners (tcp, udp, tun/tap)
└── go.mod # Defines local replacement
```
## CONVENTIONS
- **Local Replace**: `go.mod` uses `replace github.com/go-gost/x => ./x`.
- **Extensions**: Custom logic lives in `x/`. This is the primary place for modifications.
- **Handlers**: Implements SOCKS5, Tunnel, Relay, etc.
## COMMANDS
```bash
# Run
go run .
# Build
go build .
```
+37
View File
@@ -0,0 +1,37 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
## OVERVIEW
Admin API for Flux Panel. Manages users, licenses, and traffic rules.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # API Endpoints
│ ├── entity/ # DB Models (MyBatis Plus)
│ ├── mapper/ # Data Access
│ ├── service/ # Business Logic
│ └── common/ # Utils, DTOs
└── src/main/resources/
├── application.yml # Config
├── mapper/ # XML Mappers
├── data.sql # Init data
└── bgimages/ # Static resources
```
## CONVENTIONS
- **DB**: SQLite used via `sqlite-jdbc`.
- **ORM**: MyBatis Plus + MyBatis Plus Join.
- **JSON**: FastJSON2 used for serialization.
- **Utils**: Hutool used extensively.
- **Auth**: Likely custom or token-based (see `controller` logic).
## COMMANDS
```bash
# Build
mvn clean package
# Run
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
@@ -30,4 +30,13 @@ public class TunnelUpdateDto {
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
// 入口节点配置(可选,为空时不更新节点配置)
private List<ChainTunnel> inNodeId;
// 转发链节点配置(二维数组,每一跳可有多个节点)
private List<List<ChainTunnel>> chainNodes;
// 出口节点配置
private List<ChainTunnel> outNodeId;
}
@@ -322,6 +322,104 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
Tunnel existingTunnel = this.getById(tunnelUpdateDto.getId());
if (existingTunnel == null) return R.err("隧道不存在");
List<ChainTunnel> oldChainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelUpdateDto.getId())
);
boolean hasNodeChanges = detectNodeChanges(oldChainTunnels, tunnelUpdateDto);
if (hasNodeChanges && tunnelUpdateDto.getInNodeId() != null) {
List<ChainTunnel> backupChains = deepCopyChainTunnels(oldChainTunnels);
List<Long> nodeIds = new ArrayList<>();
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
nodeIds.add(inNode.getNodeId());
Node node = nodeService.getById(inNode.getNodeId());
if (node == null) return R.err("入口节点不存在: " + inNode.getNodeId());
if (node.getStatus() != 1) return R.err("入口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
}
List<ChainTunnel> newChainTunnels = new ArrayList<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
inNode.setTunnelId(existingTunnel.getId());
inNode.setChainType(1);
newChainTunnels.add(inNode);
}
if (existingTunnel.getType() == 2) {
if (tunnelUpdateDto.getOutNodeId() == null || tunnelUpdateDto.getOutNodeId().isEmpty()) {
return R.err("隧道转发类型必须配置出口节点");
}
List<List<ChainTunnel>> chainNodes = tunnelUpdateDto.getChainNodes() == null ?
new ArrayList<>() : tunnelUpdateDto.getChainNodes();
int inx = 1;
for (List<ChainTunnel> hop : chainNodes) {
for (ChainTunnel chainNode : hop) {
nodeIds.add(chainNode.getNodeId());
Node node = nodeService.getById(chainNode.getNodeId());
if (node == null) return R.err("转发链节点不存在: " + chainNode.getNodeId());
if (node.getStatus() != 1) return R.err("转发链节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(chainNode.getNodeId());
chainNode.setPort(port);
chainNode.setInx(inx);
chainNode.setChainType(2);
chainNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(chainNode);
}
inx++;
}
for (ChainTunnel outNode : tunnelUpdateDto.getOutNodeId()) {
nodeIds.add(outNode.getNodeId());
Node node = nodeService.getById(outNode.getNodeId());
if (node == null) return R.err("出口节点不存在: " + outNode.getNodeId());
if (node.getStatus() != 1) return R.err("出口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(outNode.getNodeId());
outNode.setPort(port);
outNode.setChainType(3);
outNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(outNode);
}
}
Set<Long> nodeIdSet = new HashSet<>(nodeIds);
if (nodeIdSet.size() != nodeIds.size()) {
return R.err("节点配置重复");
}
try {
cleanupGostConfig(oldChainTunnels, existingTunnel.getId());
chainTunnelService.remove(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", existingTunnel.getId())
);
R applyResult = applyNewGostConfig(tunnelUpdateDto, existingTunnel, nodes);
if (applyResult.getCode() != 0) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + applyResult.getMsg());
}
chainTunnelService.saveBatch(newChainTunnels);
} catch (Exception e) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + e.getMessage());
}
}
Tunnel tunnel = new Tunnel();
tunnel.setId(tunnelUpdateDto.getId());
tunnel.setName(tunnelUpdateDto.getName());
@@ -329,18 +427,23 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio());
tunnel.setInIp(tunnelUpdateDto.getInIp());
if (StringUtils.isEmpty(tunnel.getInIp())){
StringBuilder in_ip = new StringBuilder();
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
if (StringUtils.isEmpty(tunnel.getInIp())) {
StringBuilder inIp = new StringBuilder();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
);
for (ChainTunnel chainTunnel : chainTunnels) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null)return R.err("隧道节点数据错误,部分节点不存在");
in_ip.append(node.getServerIp()).append(",");
if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
inIp.append(node.getServerIp()).append(",");
}
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
if (inIp.length() > 0) {
inIp.deleteCharAt(inIp.length() - 1);
}
tunnel.setInIp(inIp.toString());
}
tunnel.setUpdatedTime(System.currentTimeMillis());
this.updateById(tunnel);
return R.ok();
}
@@ -706,5 +809,231 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
}
}
private boolean detectNodeChanges(List<ChainTunnel> oldChains, TunnelUpdateDto dto) {
if (dto.getInNodeId() == null) {
return false;
}
List<ChainTunnel> oldInNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
List<ChainTunnel> oldChainNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
.collect(Collectors.toList());
List<ChainTunnel> oldOutNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.collect(Collectors.toList());
Set<Long> oldInNodeIds = oldInNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newInNodeIds = dto.getInNodeId().stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldInNodeIds.equals(newInNodeIds)) {
return true;
}
List<ChainTunnel> flatNewChainNodes = (dto.getChainNodes() == null) ? new ArrayList<>() :
dto.getChainNodes().stream().flatMap(List::stream).collect(Collectors.toList());
if (oldChainNodes.size() != flatNewChainNodes.size()) {
return true;
}
for (int i = 0; i < oldChainNodes.size(); i++) {
ChainTunnel oldCt = oldChainNodes.get(i);
boolean found = flatNewChainNodes.stream().anyMatch(newCt ->
Objects.equals(oldCt.getNodeId(), newCt.getNodeId()) &&
Objects.equals(oldCt.getProtocol(), newCt.getProtocol()) &&
Objects.equals(oldCt.getStrategy(), newCt.getStrategy()) &&
Objects.equals(oldCt.getInx(), newCt.getInx())
);
if (!found) {
return true;
}
}
List<ChainTunnel> newOutNodes = (dto.getOutNodeId() == null) ? new ArrayList<>() : dto.getOutNodeId();
if (oldOutNodes.size() != newOutNodes.size()) {
return true;
}
Set<Long> oldOutNodeIds = oldOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newOutNodeIds = newOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldOutNodeIds.equals(newOutNodeIds)) {
return true;
}
for (ChainTunnel oldOut : oldOutNodes) {
boolean found = newOutNodes.stream().anyMatch(newOut ->
Objects.equals(oldOut.getNodeId(), newOut.getNodeId()) &&
Objects.equals(oldOut.getProtocol(), newOut.getProtocol()) &&
Objects.equals(oldOut.getStrategy(), newOut.getStrategy())
);
if (!found) {
return true;
}
}
return false;
}
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
for (ChainTunnel chainTunnel : chainTunnels) {
if (chainTunnel.getChainType() == 1) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
} else if (chainTunnel.getChainType() == 2) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
} else if (chainTunnel.getChainType() == 3) {
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
}
}
private List<ChainTunnel> deepCopyChainTunnels(List<ChainTunnel> original) {
List<ChainTunnel> copy = new ArrayList<>();
for (ChainTunnel ct : original) {
ChainTunnel newCt = new ChainTunnel();
newCt.setId(ct.getId());
newCt.setTunnelId(ct.getTunnelId());
newCt.setChainType(ct.getChainType());
newCt.setNodeId(ct.getNodeId());
newCt.setPort(ct.getPort());
newCt.setStrategy(ct.getStrategy());
newCt.setInx(ct.getInx());
newCt.setProtocol(ct.getProtocol());
copy.add(newCt);
}
return copy;
}
private R applyNewGostConfig(TunnelUpdateDto dto, Tunnel tunnel, Map<Long, Node> nodes) {
List<JSONObject> chainSuccess = new ArrayList<>();
List<JSONObject> serviceSuccess = new ArrayList<>();
if (tunnel.getType() == 2) {
List<List<ChainTunnel>> chainNodes = dto.getChainNodes() == null ? new ArrayList<>() : dto.getChainNodes();
for (ChainTunnel inNode : dto.getInNodeId()) {
GostDto gostDto;
if (chainNodes.isEmpty()) {
gostDto = GostUtil.AddChains(inNode.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(inNode.getNodeId(), chainNodes.get(0), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建入口Chain失败: " + gostDto.getMsg());
}
JSONObject data = new JSONObject();
data.put("node_id", inNode.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chainSuccess.add(data);
}
for (int i = 0; i < chainNodes.size(); i++) {
List<ChainTunnel> currentHop = chainNodes.get(i);
for (ChainTunnel chainTunnel : currentHop) {
GostDto gostDto;
if (i + 1 >= chainNodes.size()) {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), chainNodes.get(i + 1), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Chain失败: " + gostDto.getMsg());
}
JSONObject chainData = new JSONObject();
chainData.put("node_id", chainTunnel.getNodeId());
chainData.put("name", "chains_" + tunnel.getId());
chainSuccess.add(chainData);
GostDto serviceResult = GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
if (!Objects.equals(serviceResult.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Service失败: " + serviceResult.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", chainTunnel.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
for (ChainTunnel outNode : dto.getOutNodeId()) {
GostDto gostDto = GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建出口Service失败: " + gostDto.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", outNode.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
return R.ok();
}
private void rollbackGostChanges(List<JSONObject> chainSuccess, List<JSONObject> serviceSuccess) {
for (JSONObject chain : chainSuccess) {
GostUtil.DeleteChains(chain.getLong("node_id"), chain.getString("name"));
}
for (JSONObject service : serviceSuccess) {
JSONArray services = new JSONArray();
services.add(service.getString("name"));
GostUtil.DeleteService(service.getLong("node_id"), services);
}
}
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel ct : chainTunnels) {
Node node = nodeService.getById(ct.getNodeId());
if (node != null) {
nodes.put(node.getId(), node);
}
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 1)
.collect(Collectors.toList());
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 2)
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.collect(Collectors.toList());
List<ChainTunnel> outNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 3)
.collect(Collectors.toList());
if (tunnel.getType() == 2) {
for (ChainTunnel inNode : inNodes) {
if (chainNodesList.isEmpty()) {
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
}
}
for (int i = 0; i < chainNodesList.size(); i++) {
for (ChainTunnel chainTunnel : chainNodesList.get(i)) {
if (i + 1 >= chainNodesList.size()) {
GostUtil.AddChains(chainTunnel.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(chainTunnel.getNodeId(), chainNodesList.get(i + 1), nodes);
}
GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
}
}
for (ChainTunnel outNode : outNodes) {
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
# VITE FRONTEND KNOWLEDGE BASE
## OVERVIEW
Web management console for Flux Panel.
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI (NextUI).
## STRUCTURE
```
vite-frontend/
├── src/
│ ├── pages/ # Route views
│ ├── components/ # Reusable UI parts
│ ├── layouts/ # Page wrappers
│ ├── api/ # Axios wrappers
│ ├── config/ # App settings
│ └── utils/ # Helpers
├── vite.config.ts # Vite config (Base: '/')
└── package.json
```
## CONVENTIONS
- **UI Lib**: HeroUI (formerly NextUI) + Tailwind CSS 4.
- **Routing**: React Router DOM 6.
- **State**: Check `provider.tsx` or local state.
- **Build**: Output to `dist/`.
## COMMANDS
```bash
# Dev
npm run dev
# Build
npm run build
```
+3
View File
@@ -6,6 +6,7 @@ export type SiteConfig = typeof siteConfig;
const CACHE_PREFIX = 'vite_config_';
const VERSION = "2.0.7-beta";
const APP_VERSION = "1.0.3";
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
const getInitialConfig = () => {
if (typeof window === 'undefined') {
@@ -13,6 +14,7 @@ const getInitialConfig = () => {
name: "flux",
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
};
}
@@ -22,6 +24,7 @@ const getInitialConfig = () => {
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
};
}
return {
+1 -1
View File
@@ -311,7 +311,7 @@ export default function AdminLayout({
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
+8 -8
View File
@@ -308,14 +308,14 @@ export default function IndexPage() {
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
<a
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{ isWebView ? siteConfig.app_version : siteConfig.version}
+8 -8
View File
@@ -241,14 +241,14 @@ export default function ProfilePage() {
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
<a
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{ isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
+5 -14
View File
@@ -693,7 +693,7 @@ export default function TunnelPage() {
{isEdit ? '编辑隧道' : '新增隧道'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '编辑时只能修改隧道名称、流量计算和流量倍率' : '创建新的隧道配置'}
{isEdit ? '修改节点配置会中断现有连接,隧道类型不可修改' : '创建新的隧道配置'}
</p>
</ModalHeader>
<ModalBody>
@@ -803,11 +803,10 @@ export default function TunnelPage() {
});
setForm(prev => ({ ...prev, inNodeId: newInNodeId }));
}}
isInvalid={!!errors.inNodeId}
errorMessage={errors.inNodeId}
variant="bordered"
isDisabled={isEdit}
>
isInvalid={!!errors.inNodeId}
errorMessage={errors.inNodeId}
variant="bordered"
>
{nodes.map((node) => (
<SelectItem
key={node.id}
@@ -860,7 +859,6 @@ export default function TunnelPage() {
]
}));
}}
isDisabled={isEdit}
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@@ -889,7 +887,6 @@ export default function TunnelPage() {
variant="light"
isIconOnly
onPress={() => removeChainNode(groupIndex)}
isDisabled={isEdit}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -932,7 +929,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -991,7 +987,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1018,7 +1013,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1083,7 +1077,6 @@ export default function TunnelPage() {
isInvalid={!!errors.outNodeId}
errorMessage={errors.outNodeId}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1154,7 +1147,6 @@ export default function TunnelPage() {
isInvalid={!!errors.protocol}
errorMessage={errors.protocol}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1197,7 +1189,6 @@ export default function TunnelPage() {
}
}}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"