mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
24 Commits
2.0.8
...
2.0.11-beta6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ca01aba5d | |||
| 0f57ec58b3 | |||
| ac30f0172f | |||
| 531ba0bfed | |||
| d787e4b07a | |||
| 68e5d0ac0b | |||
| 38b70821c5 | |||
| 936158dd32 | |||
| e0d0553fd3 | |||
| 78aa86b23c | |||
| 1f850593dd | |||
| c4519c243a | |||
| 31ccc48436 | |||
| 1c4914ec77 | |||
| 71c40127d5 | |||
| aac1d63ac0 | |||
| 8222b31917 | |||
| 81c9c117a2 | |||
| de8e487dff | |||
| f4c59f64b0 | |||
| 6adf90d45c | |||
| 71eab8e07e | |||
| dd58ac67de | |||
| e6226d8069 |
@@ -150,7 +150,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare build args
|
- name: Prepare build args
|
||||||
run: |
|
run: |
|
||||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./vite-frontend/.env.production
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
|
{
|
||||||
|
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
|
||||||
|
echo "VITE_APP_VERSION=$VERSION"
|
||||||
|
} > ./vite-frontend/.env.production
|
||||||
|
|
||||||
- name: Build and push Vite Docker images
|
- name: Build and push Vite Docker images
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
active: true
|
||||||
|
iteration: 1
|
||||||
|
max_iterations: 100
|
||||||
|
completion_promise: "DONE"
|
||||||
|
started_at: "2026-01-24T05:20:50.887Z"
|
||||||
|
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
|
||||||
|
---
|
||||||
|
Complete the task as instructed
|
||||||
+83
-10
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# GitHub repo used for release downloads
|
||||||
|
REPO="Sagit-chu/flux-panel"
|
||||||
|
|
||||||
# 获取系统架构
|
# 获取系统架构
|
||||||
get_architecture() {
|
get_architecture() {
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
@@ -16,20 +19,90 @@ get_architecture() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 安装目录
|
||||||
|
INSTALL_DIR="/etc/flux_agent"
|
||||||
|
|
||||||
|
# 识别国家(用于镜像加速)
|
||||||
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
|
|
||||||
|
maybe_proxy_url() {
|
||||||
|
local url="$1"
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
echo "https://ghfast.top/${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest_release_tag() {
|
||||||
|
local effective_url tag api_tag latest_url api_url
|
||||||
|
|
||||||
|
latest_url="https://github.com/${REPO}/releases/latest"
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 方式2:GitHub API(无需 jq)
|
||||||
|
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
echo "$VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||||
|
echo "$FLUX_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolve_latest_release_tag; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# 构建下载地址
|
# 构建下载地址
|
||||||
build_download_url() {
|
build_download_url() {
|
||||||
local ARCH=$(get_architecture)
|
local ARCH=$(get_architecture)
|
||||||
echo "https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/gost-${ARCH}"
|
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 下载地址
|
# 解析版本并构建下载地址
|
||||||
DOWNLOAD_URL=$(build_download_url)
|
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||||
INSTALL_DIR="/etc/flux_agent"
|
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
|
||||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -365,4 +438,4 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 执行主函数
|
# 执行主函数
|
||||||
main
|
main
|
||||||
|
|||||||
+74
-8
@@ -7,16 +7,82 @@ export LC_ALL=C
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 全局下载地址配置
|
# GitHub repo used for release downloads
|
||||||
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/docker-compose-v4.yml"
|
REPO="Sagit-chu/flux-panel"
|
||||||
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/docker-compose-v6.yml"
|
|
||||||
|
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
maybe_proxy_url() {
|
||||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
local url="$1"
|
||||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
fi
|
echo "https://ghfast.top/${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest_release_tag() {
|
||||||
|
local effective_url tag api_tag latest_url api_url
|
||||||
|
|
||||||
|
latest_url="https://github.com/${REPO}/releases/latest"
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||||
|
tag="${effective_url##*/}"
|
||||||
|
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||||
|
echo "$tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||||
|
if [[ -n "$api_tag" ]]; then
|
||||||
|
echo "$api_tag"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
echo "$VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||||
|
echo "$FLUX_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolve_latest_release_tag; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./panel_install.sh" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||||
|
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||||
|
|
||||||
|
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
|
||||||
|
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<factorypath>
|
||||||
|
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
|
||||||
|
</factorypath>
|
||||||
@@ -20,6 +20,8 @@ public class ForwardUpdateDto {
|
|||||||
@NotBlank(message = "转发名称不能为空")
|
@NotBlank(message = "转发名称不能为空")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
private Integer tunnelId;
|
||||||
|
|
||||||
@NotBlank(message = "远程地址不能为空")
|
@NotBlank(message = "远程地址不能为空")
|
||||||
private String remoteAddr;
|
private String remoteAddr;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public class NodeDto {
|
|||||||
@NotBlank(message = "服务器ip不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
@NotBlank(message = "可用端口不能为空")
|
@NotBlank(message = "可用端口不能为空")
|
||||||
private String port;
|
private String port;
|
||||||
|
|
||||||
@@ -25,4 +29,4 @@ public class NodeDto {
|
|||||||
|
|
||||||
private String udpListenAddr = "0.0.0.0";
|
private String udpListenAddr = "0.0.0.0";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
|
|||||||
@NotBlank(message = "服务器ip不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
@NotBlank(message = "可用port不能为空")
|
@NotBlank(message = "可用port不能为空")
|
||||||
private String port;
|
private String port;
|
||||||
|
|
||||||
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
|
|||||||
private String tcpListenAddr = "0.0.0.0";
|
private String tcpListenAddr = "0.0.0.0";
|
||||||
|
|
||||||
private String udpListenAddr = "0.0.0.0";
|
private String udpListenAddr = "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import java.util.List;
|
|||||||
public class TunnelDetailDto {
|
public class TunnelDetailDto {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
|
|||||||
// 出口节点列表
|
// 出口节点列表
|
||||||
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.admin.common.migration;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight SQLite schema migration.
|
||||||
|
*
|
||||||
|
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
|
||||||
|
* won't automatically receive new columns. This runner adds missing columns in-place.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class SqliteSchemaMigration implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumn(String table, String column, String columnDefinition) {
|
||||||
|
Set<String> columns = new HashSet<>(
|
||||||
|
jdbcTemplate.query(
|
||||||
|
"PRAGMA table_info(" + table + ")",
|
||||||
|
(rs, rowNum) -> rs.getString("name")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columns.contains(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Adding missing column {}.{}", table, column);
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONArray;
|
|||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -43,6 +44,7 @@ public class GostUtil {
|
|||||||
|
|
||||||
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
|
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
|
||||||
JSONArray nodes = new JSONArray();
|
JSONArray nodes = new JSONArray();
|
||||||
|
Node fromNode = node_s.get(node_id);
|
||||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
JSONObject dialer = new JSONObject();
|
JSONObject dialer = new JSONObject();
|
||||||
dialer.put("type", chainTunnel.getProtocol());
|
dialer.put("type", chainTunnel.getProtocol());
|
||||||
@@ -53,7 +55,11 @@ public class GostUtil {
|
|||||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||||
JSONObject node = new JSONObject();
|
JSONObject node = new JSONObject();
|
||||||
node.put("name", "node_" + chainTunnel.getInx());
|
node.put("name", "node_" + chainTunnel.getInx());
|
||||||
node.put("addr", processServerAddress(node_info.getServerIp() + ":" + chainTunnel.getPort()));
|
|
||||||
|
String dialHost = (fromNode != null && node_info != null)
|
||||||
|
? selectDialHost(fromNode, node_info)
|
||||||
|
: (node_info != null ? node_info.getServerIp() : null);
|
||||||
|
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
|
||||||
node.put("connector", connector);
|
node.put("connector", connector);
|
||||||
node.put("dialer", dialer);
|
node.put("dialer", dialer);
|
||||||
|
|
||||||
@@ -291,4 +297,115 @@ public class GostUtil {
|
|||||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||||
return colonCount >= 2;
|
return colonCount >= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
|
||||||
|
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
|
||||||
|
*/
|
||||||
|
public static String selectDialHost(Node fromNode, Node toNode) {
|
||||||
|
if (fromNode == null || toNode == null) {
|
||||||
|
throw new IllegalArgumentException("node is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean fromV4 = supportsV4(fromNode);
|
||||||
|
boolean fromV6 = supportsV6(fromNode);
|
||||||
|
boolean toV4 = supportsV4(toNode);
|
||||||
|
boolean toV6 = supportsV6(toNode);
|
||||||
|
|
||||||
|
if (fromV4 && toV4) {
|
||||||
|
return pickToAddressV4(toNode);
|
||||||
|
}
|
||||||
|
if (fromV6 && toV6) {
|
||||||
|
return pickToAddressV6(toNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
|
||||||
|
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeName(Node node) {
|
||||||
|
if (node.getName() == null || node.getName().isBlank()) {
|
||||||
|
return "node_" + node.getId();
|
||||||
|
}
|
||||||
|
return node.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean supportsV4(Node node) {
|
||||||
|
// New dual-stack fields take precedence over legacy serverIp.
|
||||||
|
// If user explicitly provided only v6, treat as v6-only.
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacy = node.getServerIp();
|
||||||
|
if (StrUtil.isBlank(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy = legacy.trim();
|
||||||
|
if (looksLikeIpv4(legacy)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isIPv6Address(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean supportsV6(Node node) {
|
||||||
|
// New dual-stack fields take precedence over legacy serverIp.
|
||||||
|
// If user explicitly provided only v4, treat as v4-only.
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacy = node.getServerIp();
|
||||||
|
if (StrUtil.isBlank(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy = legacy.trim();
|
||||||
|
if (isIPv6Address(legacy)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (looksLikeIpv4(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pickToAddressV4(Node toNode) {
|
||||||
|
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
|
||||||
|
return toNode.getServerIpV4().trim();
|
||||||
|
}
|
||||||
|
String legacy = toNode.getServerIp();
|
||||||
|
return legacy != null ? legacy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pickToAddressV6(Node toNode) {
|
||||||
|
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
|
||||||
|
return toNode.getServerIpV6().trim();
|
||||||
|
}
|
||||||
|
String legacy = toNode.getServerIp();
|
||||||
|
return legacy != null ? legacy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean looksLikeIpv4(String value) {
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
return ipv4.matcher(value).matches();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import org.springframework.stereotype.Component;
|
|||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQLite 数据库配置
|
* SQLite 数据库配置
|
||||||
@@ -39,13 +44,98 @@ public class SQLiteConfig implements ApplicationRunner {
|
|||||||
statement.execute("PRAGMA temp_store=MEMORY;");
|
statement.execute("PRAGMA temp_store=MEMORY;");
|
||||||
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
|
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
|
||||||
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
|
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
|
||||||
|
|
||||||
|
ensureNodeDualStackColumns(connection);
|
||||||
|
|
||||||
log.info("SQLite WAL mode configured successfully");
|
log.info("SQLite WAL mode configured successfully");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to configure SQLite database", e);
|
log.error("Failed to configure SQLite database", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
|
||||||
|
Set<String> cols = getTableColumns(connection, "node");
|
||||||
|
if (cols.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
|
||||||
|
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
|
||||||
|
|
||||||
|
backfillNodeDualStackColumns(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
|
||||||
|
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
|
||||||
|
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
|
||||||
|
) {
|
||||||
|
while (rs.next()) {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
String serverIp = rs.getString("server_ip");
|
||||||
|
String v4 = rs.getString("server_ip_v4");
|
||||||
|
String v6 = rs.getString("server_ip_v6");
|
||||||
|
|
||||||
|
if (serverIp == null || serverIp.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = serverIp.trim();
|
||||||
|
|
||||||
|
if (ipv4.matcher(trimmed).matches()) {
|
||||||
|
updV4.setString(1, trimmed);
|
||||||
|
updV4.setLong(2, id);
|
||||||
|
updV4.executeUpdate();
|
||||||
|
} else {
|
||||||
|
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
|
||||||
|
if (colonCount >= 2) {
|
||||||
|
updV6.setString(1, trimmed);
|
||||||
|
updV6.setLong(2, id);
|
||||||
|
updV6.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
|
||||||
|
Set<String> cols = new HashSet<>();
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
cols.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumnIfMissing(
|
||||||
|
Connection connection,
|
||||||
|
Set<String> existingColumns,
|
||||||
|
String table,
|
||||||
|
String column,
|
||||||
|
String type
|
||||||
|
) throws Exception {
|
||||||
|
if (existingColumns.contains(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("SQLite schema updated: added {}.{}", table, column);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
|
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
|
||||||
@@ -80,4 +170,3 @@ public class SQLiteConfig implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,24 @@ public class NodeController extends BaseController {
|
|||||||
return nodeService.deleteNode(id);
|
return nodeService.deleteNode(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@LogAnnotation
|
@LogAnnotation
|
||||||
@RequireRole
|
@RequireRole
|
||||||
@PostMapping("/install")
|
@PostMapping("/install")
|
||||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||||
Long id = Long.valueOf(params.get("id").toString());
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
return nodeService.getInstallCommand(id);
|
return nodeService.getInstallCommand(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 更新节点排序
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return nodeService.updateNodeOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,4 +137,16 @@ public class TunnelController extends BaseController {
|
|||||||
return tunnelService.diagnoseTunnel(tunnelId);
|
return tunnelService.diagnoseTunnel(tunnelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新隧道排序
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return tunnelService.updateTunnelOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,19 @@ import lombok.EqualsAndHashCode;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
public class Node extends BaseEntity {
|
public class Node extends BaseEntity {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
private String version;
|
private String version;
|
||||||
|
|
||||||
@@ -38,6 +42,11 @@ public class Node extends BaseEntity {
|
|||||||
|
|
||||||
private String tcpListenAddr;
|
private String tcpListenAddr;
|
||||||
|
|
||||||
private String udpListenAddr;
|
private String udpListenAddr;
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import lombok.EqualsAndHashCode;
|
|||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@TableName(autoResultMap = true)
|
@TableName(autoResultMap = true)
|
||||||
public class Tunnel extends BaseEntity {
|
public class Tunnel extends BaseEntity {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity {
|
|||||||
|
|
||||||
private int flow;
|
private int flow;
|
||||||
|
|
||||||
private BigDecimal trafficRatio;
|
private BigDecimal trafficRatio;
|
||||||
|
|
||||||
private String inIp;
|
private String inIp;
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.admin.service;
|
|||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.NodeDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
import com.admin.entity.Node;
|
import com.admin.entity.Node;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
@@ -14,7 +16,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
* @author QAQ
|
* @author QAQ
|
||||||
* @since 2025-06-03
|
* @since 2025-06-03
|
||||||
*/
|
*/
|
||||||
public interface NodeService extends IService<Node> {
|
public interface NodeService extends IService<Node> {
|
||||||
|
|
||||||
R createNode(NodeDto nodeDto);
|
R createNode(NodeDto nodeDto);
|
||||||
|
|
||||||
@@ -24,6 +26,12 @@ public interface NodeService extends IService<Node> {
|
|||||||
|
|
||||||
R deleteNode(Long id);
|
R deleteNode(Long id);
|
||||||
|
|
||||||
R getInstallCommand(Long id);
|
R getInstallCommand(Long id);
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 更新节点排序(管理员)
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateNodeOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import com.admin.common.dto.TunnelDto;
|
|||||||
import com.admin.common.dto.TunnelUpdateDto;
|
import com.admin.common.dto.TunnelUpdateDto;
|
||||||
|
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
import com.admin.entity.Tunnel;
|
import com.admin.entity.Tunnel;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
* @author QAQ
|
* @author QAQ
|
||||||
* @since 2025-06-03
|
* @since 2025-06-03
|
||||||
*/
|
*/
|
||||||
public interface TunnelService extends IService<Tunnel> {
|
public interface TunnelService extends IService<Tunnel> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建隧道
|
* 创建隧道
|
||||||
@@ -55,5 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
|
|||||||
* @param tunnelId 隧道ID
|
* @param tunnelId 隧道ID
|
||||||
* @return 诊断结果
|
* @return 诊断结果
|
||||||
*/
|
*/
|
||||||
R diagnoseTunnel(Long tunnelId);
|
R diagnoseTunnel(Long tunnelId);
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 更新隧道排序(管理员)
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateTunnelOrder(Map<String, Object> params);
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,56 +209,93 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public R updateForward(ForwardUpdateDto forwardUpdateDto) {
|
public R updateForward(ForwardUpdateDto forwardUpdateDto) {
|
||||||
// 1. 获取当前用户信息
|
|
||||||
UserInfo currentUser = getCurrentUserInfo();
|
UserInfo currentUser = getCurrentUserInfo();
|
||||||
|
|
||||||
|
|
||||||
// 2. 检查转发是否存在
|
|
||||||
Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser);
|
Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser);
|
||||||
if (existForward == null) {
|
if (existForward == null) {
|
||||||
return R.err("转发不存在");
|
return R.err("转发不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Integer oldTunnelId = existForward.getTunnelId();
|
||||||
|
Integer newTunnelId = forwardUpdateDto.getTunnelId() != null ? forwardUpdateDto.getTunnelId() : oldTunnelId;
|
||||||
|
boolean tunnelChanged = !Objects.equals(oldTunnelId, newTunnelId);
|
||||||
|
|
||||||
Tunnel tunnel = validateTunnel(existForward.getTunnelId());
|
Tunnel oldTunnel = validateTunnel(oldTunnelId);
|
||||||
if (tunnel == null) {
|
if (oldTunnel == null) {
|
||||||
return R.err("隧道不存在");
|
return R.err("原隧道不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, null);
|
UserTunnel oldUserTunnel;
|
||||||
if (permissionResult.isHasError()) {
|
|
||||||
return R.err(permissionResult.getErrorMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
UserTunnel userTunnel;
|
|
||||||
if (currentUser.getRoleId() != 0) {
|
if (currentUser.getRoleId() != 0) {
|
||||||
userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue());
|
oldUserTunnel = getUserTunnel(currentUser.getUserId(), oldTunnelId);
|
||||||
if (userTunnel == null) {
|
if (oldUserTunnel == null) {
|
||||||
return R.err("你没有该隧道权限");
|
return R.err("你没有原隧道权限");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 管理员用户也需要获取UserTunnel(如果存在的话),用于构建正确的服务名称
|
oldUserTunnel = getUserTunnel(existForward.getUserId(), oldTunnelId);
|
||||||
// 通过forward记录获取原始的用户ID
|
}
|
||||||
userTunnel = getUserTunnel(existForward.getUserId(), tunnel.getId().intValue());
|
|
||||||
|
if (tunnelChanged) {
|
||||||
|
Tunnel newTunnel = validateTunnel(newTunnelId);
|
||||||
|
if (newTunnel == null) {
|
||||||
|
return R.err("新隧道不存在");
|
||||||
|
}
|
||||||
|
if (newTunnel.getStatus() != 1) {
|
||||||
|
return R.err("新隧道已禁用,无法切换");
|
||||||
|
}
|
||||||
|
|
||||||
|
UserPermissionResult newPermResult = checkUserPermissions(currentUser, newTunnel, null);
|
||||||
|
if (newPermResult.isHasError()) {
|
||||||
|
return R.err(newPermResult.getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
UserTunnel newUserTunnel;
|
||||||
|
if (currentUser.getRoleId() != 0) {
|
||||||
|
newUserTunnel = getUserTunnel(currentUser.getUserId(), newTunnelId);
|
||||||
|
if (newUserTunnel == null) {
|
||||||
|
return R.err("你没有新隧道权限");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newUserTunnel = getUserTunnel(existForward.getUserId(), newTunnelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseOldTunnelResources(existForward, oldTunnel, oldUserTunnel);
|
||||||
|
|
||||||
|
existForward.setTunnelId(newTunnelId);
|
||||||
|
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
|
||||||
|
existForward.setName(forwardUpdateDto.getName());
|
||||||
|
existForward.setStrategy(forwardUpdateDto.getStrategy());
|
||||||
|
existForward.setStatus(1);
|
||||||
|
existForward.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
this.updateById(existForward);
|
||||||
|
|
||||||
|
R allocResult = allocateNewTunnelResources(existForward, newTunnel, forwardUpdateDto.getInPort(), newPermResult, newUserTunnel);
|
||||||
|
if (allocResult.getCode() != 0) {
|
||||||
|
throw new RuntimeException(allocResult.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
UserPermissionResult permissionResult = checkUserPermissions(currentUser, oldTunnel, null);
|
||||||
|
if (permissionResult.isHasError()) {
|
||||||
|
return R.err(permissionResult.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
|
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
|
||||||
existForward.setName(forwardUpdateDto.getName());
|
existForward.setName(forwardUpdateDto.getName());
|
||||||
existForward.setStrategy(forwardUpdateDto.getStrategy());
|
existForward.setStrategy(forwardUpdateDto.getStrategy());
|
||||||
existForward.setStatus(1);
|
existForward.setStatus(1);
|
||||||
|
existForward.setUpdatedTime(System.currentTimeMillis());
|
||||||
this.updateById(existForward);
|
this.updateById(existForward);
|
||||||
|
|
||||||
|
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", oldTunnel.getId()).eq("chain_type", 1));
|
||||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
|
|
||||||
|
|
||||||
// 自己占用的应该不算
|
|
||||||
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort(), existForward.getId());
|
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort(), existForward.getId());
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), userTunnel);
|
String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), oldUserTunnel);
|
||||||
Integer limiter = permissionResult.getLimiter();
|
Integer limiter = permissionResult.getLimiter();
|
||||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
if (node == null) {
|
if (node == null) {
|
||||||
@@ -270,13 +307,89 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
}
|
}
|
||||||
forwardPort.setPort(chainTunnel.getPort());
|
forwardPort.setPort(chainTunnel.getPort());
|
||||||
forwardPortService.updateById(forwardPort);
|
forwardPortService.updateById(forwardPort);
|
||||||
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, tunnel, "UpdateService");
|
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, oldTunnel, "UpdateService");
|
||||||
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
|
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
|
||||||
}
|
}
|
||||||
|
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void releaseOldTunnelResources(Forward forward, Tunnel oldTunnel, UserTunnel oldUserTunnel) {
|
||||||
|
List<ChainTunnel> oldInNodes = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>()
|
||||||
|
.eq("tunnel_id", oldTunnel.getId())
|
||||||
|
.eq("chain_type", 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (ChainTunnel chainTunnel : oldInNodes) {
|
||||||
|
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel);
|
||||||
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
|
if (node != null) {
|
||||||
|
JSONArray services = new JSONArray();
|
||||||
|
services.add(serviceName + "_tcp");
|
||||||
|
services.add(serviceName + "_udp");
|
||||||
|
GostUtil.DeleteService(node.getId(), services);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private R allocateNewTunnelResources(Forward forward, Tunnel newTunnel, Integer requestedPort,
|
||||||
|
UserPermissionResult permResult, UserTunnel newUserTunnel) {
|
||||||
|
List<ChainTunnel> newInNodes = chainTunnelService.list(
|
||||||
|
new QueryWrapper<ChainTunnel>()
|
||||||
|
.eq("tunnel_id", newTunnel.getId())
|
||||||
|
.eq("chain_type", 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
newInNodes = get_port(newInNodes, requestedPort, forward.getId());
|
||||||
|
|
||||||
|
List<JSONObject> successServices = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ChainTunnel chainTunnel : newInNodes) {
|
||||||
|
ForwardPort forwardPort = new ForwardPort();
|
||||||
|
forwardPort.setForwardId(forward.getId());
|
||||||
|
forwardPort.setNodeId(chainTunnel.getNodeId());
|
||||||
|
forwardPort.setPort(chainTunnel.getPort());
|
||||||
|
forwardPortService.save(forwardPort);
|
||||||
|
|
||||||
|
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), newUserTunnel);
|
||||||
|
Integer limiter = permResult.getLimiter();
|
||||||
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
|
|
||||||
|
if (node == null) {
|
||||||
|
rollbackCreatedServices(successServices);
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||||
|
return R.err("新隧道部分节点不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, forwardPort, newTunnel, "AddService");
|
||||||
|
|
||||||
|
if (!Objects.equals(gostDto.getMsg(), "OK")) {
|
||||||
|
rollbackCreatedServices(successServices);
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||||
|
return R.err("在新隧道创建服务失败: " + gostDto.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("node_id", node.getId());
|
||||||
|
data.put("name", serviceName);
|
||||||
|
successServices.add(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rollbackCreatedServices(List<JSONObject> created) {
|
||||||
|
for (JSONObject jsonObject : created) {
|
||||||
|
JSONArray se = new JSONArray();
|
||||||
|
se.add(jsonObject.getString("name") + "_tcp");
|
||||||
|
se.add(jsonObject.getString("name") + "_udp");
|
||||||
|
GostUtil.DeleteService(jsonObject.getLong("node_id"), se);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R deleteForward(Long id) {
|
public R deleteForward(Long id) {
|
||||||
|
|
||||||
@@ -435,7 +548,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
Node toNode = nodeService.getById(firstChainNode.getNodeId());
|
Node toNode = nodeService.getById(firstChainNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
|
||||||
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
|
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(1);
|
result.setFromChainType(1);
|
||||||
@@ -449,7 +562,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
|
||||||
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(1);
|
result.setFromChainType(1);
|
||||||
@@ -474,7 +587,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
Node toNode = nodeService.getById(nextNode.getNodeId());
|
Node toNode = nodeService.getById(nextNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), nextNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
|
||||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
|
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(2);
|
result.setFromChainType(2);
|
||||||
@@ -489,7 +602,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
|||||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
|
||||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(2);
|
result.setFromChainType(2);
|
||||||
|
|||||||
@@ -9,26 +9,34 @@ import com.admin.common.lang.R;
|
|||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.common.utils.WebSocketServer;
|
import com.admin.common.utils.WebSocketServer;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.mapper.NodeMapper;
|
import com.admin.mapper.NodeMapper;
|
||||||
import com.admin.mapper.TunnelMapper;
|
import com.admin.mapper.TunnelMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import org.springframework.beans.BeanUtils;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import java.util.List;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
@Service
|
||||||
|
@Slf4j
|
||||||
@Service
|
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
|
||||||
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@@ -38,33 +46,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
@Resource
|
@Resource
|
||||||
ViteConfigService viteConfigService;
|
ViteConfigService viteConfigService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
ChainTunnelService chainTunnelService;
|
ChainTunnelService chainTunnelService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
ForwardPortService forwardPortService;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R createNode(NodeDto nodeDto) {
|
public R createNode(NodeDto nodeDto) {
|
||||||
validatePortRange(nodeDto.getPort());
|
validatePortRange(nodeDto.getPort());
|
||||||
Node node = new Node();
|
|
||||||
node.setSecret(IdUtil.simpleUUID());
|
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
|
||||||
node.setStatus(0);
|
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
|
||||||
node.setPort(nodeDto.getPort());
|
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
|
||||||
node.setName(nodeDto.getName());
|
|
||||||
node.setServerIp(nodeDto.getServerIp());
|
Node node = new Node();
|
||||||
long currentTime = System.currentTimeMillis();
|
node.setSecret(IdUtil.simpleUUID());
|
||||||
node.setCreatedTime(currentTime);
|
node.setStatus(0);
|
||||||
node.setUpdatedTime(currentTime);
|
node.setPort(nodeDto.getPort());
|
||||||
node.setInterfaceName(nodeDto.getInterfaceName());
|
node.setName(nodeDto.getName());
|
||||||
this.save(node);
|
node.setServerIp(primaryServerIp);
|
||||||
return R.ok();
|
node.setServerIpV4(normalizedV4);
|
||||||
}
|
node.setServerIpV6(normalizedV6);
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
node.setCreatedTime(currentTime);
|
||||||
|
node.setUpdatedTime(currentTime);
|
||||||
|
node.setInterfaceName(nodeDto.getInterfaceName());
|
||||||
|
this.save(node);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getAllNodes() {
|
public R getAllNodes() {
|
||||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status"));
|
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||||
nodeList.forEach(node -> node.setSecret(null));
|
nodeList.forEach(node -> node.setSecret(null));
|
||||||
return R.ok(nodeList);
|
return R.ok(nodeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public R updateNodeOrder(Map<String, Object> params) {
|
||||||
|
if (!params.containsKey("nodes")) {
|
||||||
|
return R.err("缺少nodes参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
|
||||||
|
if (nodesList == null || nodesList.isEmpty()) {
|
||||||
|
return R.err("nodes参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Node> nodesToUpdate = new ArrayList<>();
|
||||||
|
for (Map<String, Object> nodeData : nodesList) {
|
||||||
|
Long id = Long.valueOf(nodeData.get("id").toString());
|
||||||
|
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
|
||||||
|
|
||||||
|
Node node = new Node();
|
||||||
|
node.setId(id);
|
||||||
|
node.setInx(inx);
|
||||||
|
nodesToUpdate.add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBatchById(nodesToUpdate);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||||
@@ -101,20 +147,232 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R deleteNode(Long id) {
|
public R deleteNode(Long id) {
|
||||||
Node node = this.getById(id);
|
Node node = this.getById(id);
|
||||||
if (node == null) {
|
if (node == null) {
|
||||||
return R.err("节点不存在");
|
return R.err("节点不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id"));
|
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||||
for (ChainTunnel tunnel : list) {
|
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||||
tunnelService.deleteTunnel(tunnel.getTunnelId());
|
.filter(ct -> ct.getTunnelId() != null)
|
||||||
}
|
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||||
this.removeById(id);
|
|
||||||
return R.ok();
|
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
|
||||||
}
|
Long tunnelId = entry.getKey();
|
||||||
|
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||||
|
|
||||||
|
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
|
||||||
|
// Remove the node from the tunnel definition (do NOT delete the tunnel).
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
|
||||||
|
|
||||||
|
if (tunnel == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
|
||||||
|
|
||||||
|
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
|
||||||
|
String oldDerivedInIp = buildDerivedInIp(before);
|
||||||
|
String newDerivedInIp = buildDerivedInIp(after);
|
||||||
|
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
|
||||||
|
updateTunnelInIp(tunnelId, newDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean valid = isTunnelConfigValid(tunnel, after);
|
||||||
|
if (!valid) {
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
|
||||||
|
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(after, tunnelId);
|
||||||
|
rebuildGostConfig(after, tunnel);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
|
||||||
|
try {
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long inCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.count();
|
||||||
|
if (inCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
long outCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||||
|
.count();
|
||||||
|
return outCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
|
||||||
|
if (StrUtil.isBlank(currentInIp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (oldDerivedInIp == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(currentInIp, oldDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnelId);
|
||||||
|
update.setInIp(derivedInIp == null ? "" : derivedInIp);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (inNodes.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder inIp = new StringBuilder();
|
||||||
|
for (ChainTunnel inNode : inNodes) {
|
||||||
|
Node n = this.getById(inNode.getNodeId());
|
||||||
|
if (n == null || StrUtil.isBlank(n.getServerIp())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
inIp.append(n.getServerIp()).append(",");
|
||||||
|
}
|
||||||
|
inIp.deleteCharAt(inIp.length() - 1);
|
||||||
|
return inIp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
|
||||||
|
try {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnel.getId());
|
||||||
|
update.setStatus(0);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(remaining, tunnel.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
|
if (chainTunnel.getChainType() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, Node> nodes = new HashMap<>();
|
||||||
|
for (ChainTunnel ct : chainTunnels) {
|
||||||
|
Node n = this.getById(ct.getNodeId());
|
||||||
|
if (n != null) {
|
||||||
|
nodes.put(n.getId(), n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && 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() != null && ct.getChainType() == 3)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && 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 chainNode : chainNodesList.get(i)) {
|
||||||
|
if (i + 1 >= chainNodesList.size()) {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
|
||||||
|
} else {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
|
||||||
|
}
|
||||||
|
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChainTunnel outNode : outNodes) {
|
||||||
|
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -126,8 +384,8 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||||
StringBuilder command = new StringBuilder();
|
StringBuilder command = new StringBuilder();
|
||||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/install.sh")
|
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||||
command.append("./install.sh")
|
command.append("./install.sh")
|
||||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||||
@@ -137,22 +395,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||||
validatePortRange(nodeUpdateDto.getPort());
|
validatePortRange(nodeUpdateDto.getPort());
|
||||||
Node node = new Node();
|
|
||||||
node.setId(nodeUpdateDto.getId());
|
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
|
||||||
node.setName(nodeUpdateDto.getName());
|
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
|
||||||
node.setServerIp(nodeUpdateDto.getServerIp());
|
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
|
||||||
node.setPort(nodeUpdateDto.getPort());
|
|
||||||
node.setHttp(nodeUpdateDto.getHttp());
|
Node node = new Node();
|
||||||
node.setTls(nodeUpdateDto.getTls());
|
node.setId(nodeUpdateDto.getId());
|
||||||
node.setSocks(nodeUpdateDto.getSocks());
|
node.setName(nodeUpdateDto.getName());
|
||||||
node.setUpdatedTime(System.currentTimeMillis());
|
node.setServerIp(primaryServerIp);
|
||||||
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
|
node.setServerIpV4(normalizedV4);
|
||||||
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
|
node.setServerIpV6(normalizedV6);
|
||||||
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
|
node.setPort(nodeUpdateDto.getPort());
|
||||||
return node;
|
node.setHttp(nodeUpdateDto.getHttp());
|
||||||
}
|
node.setTls(nodeUpdateDto.getTls());
|
||||||
|
node.setSocks(nodeUpdateDto.getSocks());
|
||||||
|
node.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
|
||||||
|
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
|
||||||
|
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||||
|
return serverIpV4.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||||
|
return serverIpV6.trim();
|
||||||
|
}
|
||||||
|
return fallback != null ? fallback.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeV4(String serverIpV4, String legacyServerIp) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||||
|
return serverIpV4.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
|
||||||
|
return legacyServerIp.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeV6(String serverIpV6, String legacyServerIp) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||||
|
return serverIpV6.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
|
||||||
|
return legacyServerIp.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeIpv4(String value) {
|
||||||
|
// 仅用于判定地址族(不解析域名)
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
return ipv4.matcher(value).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeIpv6(String value) {
|
||||||
|
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
|
||||||
|
long colonCount = value.chars().filter(ch -> ch == ':').count();
|
||||||
|
return colonCount >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void validatePortRange(String port) {
|
private void validatePortRange(String port) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import lombok.Data;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -116,14 +117,39 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
long currentTime = System.currentTimeMillis();
|
long currentTime = System.currentTimeMillis();
|
||||||
tunnel.setCreatedTime(currentTime);
|
tunnel.setCreatedTime(currentTime);
|
||||||
tunnel.setUpdatedTime(currentTime);
|
tunnel.setUpdatedTime(currentTime);
|
||||||
|
|
||||||
|
// When tunnels are ordered via `inx`, new tunnels should be appended.
|
||||||
|
// Only apply this when an order already exists (max `inx` > 0) to avoid
|
||||||
|
// changing behavior for deployments still relying on local ordering.
|
||||||
|
Tunnel lastByInx = this.getOne(new QueryWrapper<Tunnel>()
|
||||||
|
.select("inx")
|
||||||
|
.orderByDesc("inx")
|
||||||
|
.orderByDesc("id")
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
Integer maxInx = lastByInx == null ? null : lastByInx.getInx();
|
||||||
|
if (maxInx != null && maxInx > 0) {
|
||||||
|
tunnel.setInx(maxInx + 1);
|
||||||
|
}
|
||||||
if (StringUtils.isEmpty(tunnel.getInIp())){
|
if (StringUtils.isEmpty(tunnel.getInIp())){
|
||||||
StringBuilder in_ip = new StringBuilder();
|
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
|
||||||
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
|
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
|
||||||
Node node = nodes.get(chainTunnel.getNodeId());
|
Node node = nodes.get(chainTunnel.getNodeId());
|
||||||
in_ip.append(node.getServerIp()).append(",");
|
if (node == null) continue;
|
||||||
|
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
inIps.add(node.getServerIpV4().trim());
|
||||||
|
}
|
||||||
|
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
inIps.add(node.getServerIpV6().trim());
|
||||||
|
}
|
||||||
|
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
|
||||||
|
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
|
||||||
|
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
|
||||||
|
inIps.add(node.getServerIp().trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inIps.isEmpty()) {
|
||||||
|
tunnel.setInIp(String.join(",", inIps));
|
||||||
}
|
}
|
||||||
in_ip.deleteCharAt(in_ip.length() - 1);
|
|
||||||
tunnel.setInIp(in_ip.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.save(tunnel);
|
this.save(tunnel);
|
||||||
@@ -142,11 +168,33 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
|
for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
|
||||||
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
|
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
|
||||||
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
|
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
|
||||||
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
|
GostDto gostDto;
|
||||||
isError(gostDto);
|
try {
|
||||||
|
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
this.removeById(tunnel.getId());
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
|
||||||
|
return R.err(e.getMessage());
|
||||||
|
}
|
||||||
|
if (!Objects.equals(gostDto.getMsg(), "OK")) {
|
||||||
|
this.removeById(tunnel.getId());
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
|
||||||
|
return R.err(gostDto.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
|
GostDto gostDto;
|
||||||
|
try {
|
||||||
|
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
this.removeById(tunnel.getId());
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
|
||||||
|
for (JSONObject chainSuccess : chain_success) {
|
||||||
|
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
|
||||||
|
System.out.println(deleteChains);
|
||||||
|
}
|
||||||
|
return R.err(e.getMessage());
|
||||||
|
}
|
||||||
if (Objects.equals(gostDto.getMsg(), "OK")){
|
if (Objects.equals(gostDto.getMsg(), "OK")){
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("node_id", in_node.getNodeId());
|
data.put("node_id", in_node.getNodeId());
|
||||||
@@ -170,7 +218,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
for (ChainTunnel chainTunnel : chainTunnels1) {
|
for (ChainTunnel chainTunnel : chainTunnels1) {
|
||||||
int inx = i+1;
|
int inx = i+1;
|
||||||
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
|
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
|
||||||
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
|
GostDto gostDto;
|
||||||
|
try {
|
||||||
|
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
this.removeById(tunnel.getId());
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
|
||||||
|
for (JSONObject chainSuccess : chain_success) {
|
||||||
|
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
|
||||||
|
System.out.println(deleteChains);
|
||||||
|
}
|
||||||
|
return R.err(e.getMessage());
|
||||||
|
}
|
||||||
if (Objects.equals(gostDto.getMsg(), "OK")){
|
if (Objects.equals(gostDto.getMsg(), "OK")){
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("node_id", chainTunnel.getNodeId());
|
data.put("node_id", chainTunnel.getNodeId());
|
||||||
@@ -186,7 +245,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return R.err(gostDto.getMsg());
|
return R.err(gostDto.getMsg());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
|
GostDto gostDto;
|
||||||
|
try {
|
||||||
|
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
this.removeById(tunnel.getId());
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
|
||||||
|
for (JSONObject chainSuccess : chain_success) {
|
||||||
|
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
|
||||||
|
System.out.println(deleteChains);
|
||||||
|
}
|
||||||
|
return R.err(e.getMessage());
|
||||||
|
}
|
||||||
if (Objects.equals(gostDto.getMsg(), "OK")){
|
if (Objects.equals(gostDto.getMsg(), "OK")){
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("node_id", chainTunnel.getNodeId());
|
data.put("node_id", chainTunnel.getNodeId());
|
||||||
@@ -252,7 +322,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getAllTunnels() {
|
public R getAllTunnels() {
|
||||||
List<Tunnel> tunnelList = this.list();
|
List<Tunnel> tunnelList = this.list(new QueryWrapper<Tunnel>().orderByAsc("inx").orderByAsc("id"));
|
||||||
|
|
||||||
// 查询所有隧道的ChainTunnel信息
|
// 查询所有隧道的ChainTunnel信息
|
||||||
List<Long> tunnelIds = tunnelList.stream()
|
List<Long> tunnelIds = tunnelList.stream()
|
||||||
@@ -317,6 +387,34 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
return R.ok(detailDtoList);
|
return R.ok(detailDtoList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public R updateTunnelOrder(Map<String, Object> params) {
|
||||||
|
if (!params.containsKey("tunnels")) {
|
||||||
|
return R.err("缺少tunnels参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> tunnelsList = (List<Map<String, Object>>) params.get("tunnels");
|
||||||
|
if (tunnelsList == null || tunnelsList.isEmpty()) {
|
||||||
|
return R.err("tunnels参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Tunnel> tunnelsToUpdate = new ArrayList<>();
|
||||||
|
for (Map<String, Object> tunnelData : tunnelsList) {
|
||||||
|
Long id = Long.valueOf(tunnelData.get("id").toString());
|
||||||
|
Integer inx = Integer.valueOf(tunnelData.get("inx").toString());
|
||||||
|
|
||||||
|
Tunnel tunnel = new Tunnel();
|
||||||
|
tunnel.setId(id);
|
||||||
|
tunnel.setInx(inx);
|
||||||
|
tunnelsToUpdate.add(tunnel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBatchById(tunnelsToUpdate);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
|
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
|
||||||
@@ -428,19 +526,29 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
tunnel.setInIp(tunnelUpdateDto.getInIp());
|
tunnel.setInIp(tunnelUpdateDto.getInIp());
|
||||||
|
|
||||||
if (StringUtils.isEmpty(tunnel.getInIp())) {
|
if (StringUtils.isEmpty(tunnel.getInIp())) {
|
||||||
StringBuilder inIp = new StringBuilder();
|
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
|
||||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(
|
List<ChainTunnel> chainTunnels = chainTunnelService.list(
|
||||||
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
|
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
|
||||||
);
|
);
|
||||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
|
if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
|
||||||
inIp.append(node.getServerIp()).append(",");
|
|
||||||
|
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
inIps.add(node.getServerIpV4().trim());
|
||||||
|
}
|
||||||
|
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
inIps.add(node.getServerIpV6().trim());
|
||||||
|
}
|
||||||
|
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
|
||||||
|
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
|
||||||
|
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
|
||||||
|
inIps.add(node.getServerIp().trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (inIp.length() > 0) {
|
if (!inIps.isEmpty()) {
|
||||||
inIp.deleteCharAt(inIp.length() - 1);
|
tunnel.setInIp(String.join(",", inIps));
|
||||||
}
|
}
|
||||||
tunnel.setInIp(inIp.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnel.setUpdatedTime(System.currentTimeMillis());
|
tunnel.setUpdatedTime(System.currentTimeMillis());
|
||||||
@@ -489,7 +597,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Integer roleId = JwtUtil.getRoleIdFromToken();
|
Integer roleId = JwtUtil.getRoleIdFromToken();
|
||||||
Integer userId = JwtUtil.getUserIdFromToken();
|
Integer userId = JwtUtil.getUserIdFromToken();
|
||||||
if (roleId == 0) {
|
if (roleId == 0) {
|
||||||
tunnelEntities = this.list(new QueryWrapper<Tunnel>().eq("status", 1));
|
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
|
||||||
|
.eq("status", 1)
|
||||||
|
.orderByAsc("inx")
|
||||||
|
.orderByAsc("id"));
|
||||||
} else {
|
} else {
|
||||||
tunnelEntities = java.util.Collections.emptyList(); // 返回空列表
|
tunnelEntities = java.util.Collections.emptyList(); // 返回空列表
|
||||||
List<UserTunnel> userTunnels = userTunnelMapper.selectList(
|
List<UserTunnel> userTunnels = userTunnelMapper.selectList(
|
||||||
@@ -501,7 +612,9 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
|
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
|
||||||
.in("id", tunnelIds)
|
.in("id", tunnelIds)
|
||||||
.eq("status", 1));
|
.eq("status", 1)
|
||||||
|
.orderByAsc("inx")
|
||||||
|
.orderByAsc("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -567,7 +680,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Node toNode = nodeService.getById(firstChainNode.getNodeId());
|
Node toNode = nodeService.getById(firstChainNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
|
||||||
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
|
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(1); // 入口
|
result.setFromChainType(1); // 入口
|
||||||
@@ -581,7 +694,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
|
||||||
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(1);
|
result.setFromChainType(1);
|
||||||
@@ -605,7 +718,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Node toNode = nodeService.getById(nextNode.getNodeId());
|
Node toNode = nodeService.getById(nextNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), nextNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
|
||||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
|
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(2);
|
result.setFromChainType(2);
|
||||||
@@ -620,7 +733,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||||
if (toNode != null) {
|
if (toNode != null) {
|
||||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
|
||||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||||
);
|
);
|
||||||
result.setFromChainType(2);
|
result.setFromChainType(2);
|
||||||
@@ -711,7 +824,12 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void isError(GostDto gostDto){
|
private void isError(GostDto gostDto){
|
||||||
|
if (gostDto == null) {
|
||||||
|
throw new RuntimeException("节点无响应");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(gostDto.getMsg(), "OK")) {
|
||||||
|
throw new RuntimeException(gostDto.getMsg());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) {
|
private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
secret VARCHAR(100) NOT NULL,
|
secret VARCHAR(100) NOT NULL,
|
||||||
server_ip VARCHAR(100) NOT NULL,
|
server_ip VARCHAR(100) NOT NULL,
|
||||||
|
server_ip_v4 VARCHAR(100),
|
||||||
|
server_ip_v6 VARCHAR(100),
|
||||||
port TEXT NOT NULL,
|
port TEXT NOT NULL,
|
||||||
interface_name VARCHAR(200),
|
interface_name VARCHAR(200),
|
||||||
version VARCHAR(100),
|
version VARCHAR(100),
|
||||||
@@ -39,7 +41,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
updated_time INTEGER,
|
updated_time INTEGER,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
|
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||||
@@ -72,7 +75,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
|
|||||||
created_time INTEGER NOT NULL,
|
created_time INTEGER NOT NULL,
|
||||||
updated_time INTEGER NOT NULL,
|
updated_time INTEGER NOT NULL,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
in_ip TEXT
|
in_ip TEXT,
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||||
@@ -123,4 +127,3 @@ CREATE TABLE IF NOT EXISTS vite_config (
|
|||||||
value VARCHAR(200) NOT NULL,
|
value VARCHAR(200) NOT NULL,
|
||||||
time INTEGER NOT NULL
|
time INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.admin;
|
||||||
|
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.entity.Node;
|
||||||
|
import com.admin.entity.Tunnel;
|
||||||
|
import com.admin.service.NodeService;
|
||||||
|
import com.admin.service.TunnelService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
|
||||||
|
"DB_PATH=./target/test-gost-ordering.db",
|
||||||
|
})
|
||||||
|
class OrderingUpdateTests {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private NodeService nodeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelService tunnelService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanup() {
|
||||||
|
// keep it simple; other tables may have foreign references in real runs
|
||||||
|
jdbcTemplate.execute("DELETE FROM node");
|
||||||
|
jdbcTemplate.execute("DELETE FROM tunnel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNodeOrder_updatesInx() {
|
||||||
|
Node n1 = new Node();
|
||||||
|
n1.setName("n1");
|
||||||
|
n1.setSecret("s1");
|
||||||
|
n1.setServerIp("127.0.0.1");
|
||||||
|
n1.setPort("1000-2000");
|
||||||
|
n1.setInterfaceName("");
|
||||||
|
n1.setHttp(0);
|
||||||
|
n1.setTls(0);
|
||||||
|
n1.setSocks(0);
|
||||||
|
n1.setTcpListenAddr("[::]");
|
||||||
|
n1.setUdpListenAddr("[::]");
|
||||||
|
n1.setStatus(0);
|
||||||
|
n1.setInx(0);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
n1.setCreatedTime(now);
|
||||||
|
n1.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n1));
|
||||||
|
|
||||||
|
Node n2 = new Node();
|
||||||
|
n2.setName("n2");
|
||||||
|
n2.setSecret("s2");
|
||||||
|
n2.setServerIp("127.0.0.2");
|
||||||
|
n2.setPort("1000-2000");
|
||||||
|
n2.setInterfaceName("");
|
||||||
|
n2.setHttp(0);
|
||||||
|
n2.setTls(0);
|
||||||
|
n2.setSocks(0);
|
||||||
|
n2.setTcpListenAddr("[::]");
|
||||||
|
n2.setUdpListenAddr("[::]");
|
||||||
|
n2.setStatus(0);
|
||||||
|
n2.setInx(0);
|
||||||
|
n2.setCreatedTime(now);
|
||||||
|
n2.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n2));
|
||||||
|
|
||||||
|
Node n3 = new Node();
|
||||||
|
n3.setName("n3");
|
||||||
|
n3.setSecret("s3");
|
||||||
|
n3.setServerIp("127.0.0.3");
|
||||||
|
n3.setPort("1000-2000");
|
||||||
|
n3.setInterfaceName("");
|
||||||
|
n3.setHttp(0);
|
||||||
|
n3.setTls(0);
|
||||||
|
n3.setSocks(0);
|
||||||
|
n3.setTcpListenAddr("[::]");
|
||||||
|
n3.setUdpListenAddr("[::]");
|
||||||
|
n3.setStatus(0);
|
||||||
|
n3.setInx(0);
|
||||||
|
n3.setCreatedTime(now);
|
||||||
|
n3.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n3));
|
||||||
|
|
||||||
|
List<Map<String, Object>> nodes = new ArrayList<>();
|
||||||
|
nodes.add(mapIdInx(n2.getId(), 0));
|
||||||
|
nodes.add(mapIdInx(n1.getId(), 1));
|
||||||
|
nodes.add(mapIdInx(n3.getId(), 2));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("nodes", nodes);
|
||||||
|
|
||||||
|
R res = nodeService.updateNodeOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, nodeService.getById(n1.getId()).getInx());
|
||||||
|
assertEquals(0, nodeService.getById(n2.getId()).getInx());
|
||||||
|
assertEquals(2, nodeService.getById(n3.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateTunnelOrder_updatesInx() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Tunnel t1 = new Tunnel();
|
||||||
|
t1.setName("t1");
|
||||||
|
t1.setType(1);
|
||||||
|
t1.setFlow(1);
|
||||||
|
t1.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t1.setInIp("");
|
||||||
|
t1.setStatus(1);
|
||||||
|
t1.setInx(0);
|
||||||
|
t1.setCreatedTime(now);
|
||||||
|
t1.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t1));
|
||||||
|
|
||||||
|
Tunnel t2 = new Tunnel();
|
||||||
|
t2.setName("t2");
|
||||||
|
t2.setType(2);
|
||||||
|
t2.setFlow(2);
|
||||||
|
t2.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t2.setInIp("");
|
||||||
|
t2.setStatus(1);
|
||||||
|
t2.setInx(0);
|
||||||
|
t2.setCreatedTime(now);
|
||||||
|
t2.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t2));
|
||||||
|
|
||||||
|
List<Map<String, Object>> tunnels = new ArrayList<>();
|
||||||
|
tunnels.add(mapIdInx(t2.getId(), 0));
|
||||||
|
tunnels.add(mapIdInx(t1.getId(), 1));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("tunnels", tunnels);
|
||||||
|
|
||||||
|
R res = tunnelService.updateTunnelOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
|
||||||
|
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> mapIdInx(Long id, int inx) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", id);
|
||||||
|
m.put("inx", inx);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export const getNodeList = () => Network.post("/node/list");
|
|||||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
|
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
|
||||||
|
export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }> }) => Network.post("/node/update-order", data);
|
||||||
export const checkNodeStatus = (nodeId?: number) => {
|
export const checkNodeStatus = (nodeId?: number) => {
|
||||||
const params = nodeId ? { nodeId } : {};
|
const params = nodeId ? { nodeId } : {};
|
||||||
return Network.post("/node/check-status", params);
|
return Network.post("/node/check-status", params);
|
||||||
@@ -41,6 +42,7 @@ export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id })
|
|||||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||||
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
||||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
||||||
|
export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }> }) => Network.post("/tunnel/update-order", data);
|
||||||
|
|
||||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
||||||
@@ -88,4 +90,4 @@ export const updateConfig = (name: string, value: string) => Network.post("/conf
|
|||||||
// 验证码相关接口
|
// 验证码相关接口
|
||||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
|||||||
|
|
||||||
// 缓存相关常量
|
// 缓存相关常量
|
||||||
const CACHE_PREFIX = 'vite_config_';
|
const CACHE_PREFIX = 'vite_config_';
|
||||||
const VERSION = "2.0.7-beta";
|
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
const APP_VERSION = "1.0.3";
|
const APP_VERSION = "1.0.3";
|
||||||
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||||
|
|
||||||
|
|||||||
@@ -741,31 +741,45 @@ export default function DashboardPage() {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value: number | string) => {
|
||||||
if (value === 0) return '0';
|
const v = typeof value === 'number' ? value : Number(value);
|
||||||
if (value < 1024) return `${value}B`;
|
if (!Number.isFinite(v)) return String(value);
|
||||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`;
|
if (v === 0) return '0';
|
||||||
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`;
|
if (v < 1024) return `${v}B`;
|
||||||
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||||
}}
|
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||||
/>
|
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||||
<Tooltip
|
}}
|
||||||
content={({ active, payload, label }) => {
|
/>
|
||||||
if (active && payload && payload.length) {
|
<Tooltip
|
||||||
return (
|
content={({
|
||||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
active,
|
||||||
<p className="font-medium text-foreground">{`时间: ${label}`}</p>
|
payload,
|
||||||
<p className="text-primary">
|
label,
|
||||||
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}
|
}: {
|
||||||
</p>
|
active?: boolean;
|
||||||
</div>
|
payload?: Array<{ value?: number | string }>;
|
||||||
);
|
label?: string | number;
|
||||||
}
|
}) => {
|
||||||
return null;
|
if (active && payload && payload.length) {
|
||||||
|
const firstValue = payload[0]?.value;
|
||||||
|
const numericValue = typeof firstValue === 'number' ? firstValue : Number(firstValue);
|
||||||
|
const flowValue = Number.isFinite(numericValue) ? numericValue : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||||
|
<p className="font-medium text-foreground">{`时间: ${label ?? ''}`}</p>
|
||||||
|
<p className="text-primary">
|
||||||
|
{`流量: ${formatFlow(flowValue)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
@@ -983,4 +997,4 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Input } from "@heroui/input";
|
import { Input } from "@heroui/input";
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -353,8 +354,6 @@ export default function ForwardPage() {
|
|||||||
const userMap = new Map<string, UserGroup>();
|
const userMap = new Map<string, UserGroup>();
|
||||||
|
|
||||||
// 获取排序后的转发列表
|
// 获取排序后的转发列表
|
||||||
const sortedForwards = getSortedForwards();
|
|
||||||
|
|
||||||
sortedForwards.forEach(forward => {
|
sortedForwards.forEach(forward => {
|
||||||
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
|
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
|
||||||
const userName = forward.userName || '未知用户';
|
const userName = forward.userName || '未知用户';
|
||||||
@@ -855,7 +854,7 @@ export default function ForwardPage() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 直接显示模式下,过滤指定隧道的转发
|
// 直接显示模式下,过滤指定隧道的转发
|
||||||
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport);
|
forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forwardsToExport.length === 0) {
|
if (forwardsToExport.length === 0) {
|
||||||
@@ -1102,64 +1101,80 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
// 传感器配置 - 使用默认配置避免错误
|
// 传感器配置 - 使用默认配置避免错误
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tokenUserId = JwtUtil.getUserIdFromToken();
|
||||||
|
|
||||||
// 根据排序顺序获取转发列表
|
// 根据排序顺序获取转发列表
|
||||||
const getSortedForwards = (): Forward[] => {
|
const sortedForwards = useMemo((): Forward[] => {
|
||||||
// 确保 forwards 数组存在且有效
|
// 确保 forwards 数组存在且有效
|
||||||
if (!forwards || forwards.length === 0) {
|
if (!forwards || forwards.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在平铺模式下,只显示当前用户的转发
|
// 在平铺模式下,只显示当前用户的转发
|
||||||
let filteredForwards = forwards;
|
let filteredForwards = forwards;
|
||||||
if (viewMode === 'direct') {
|
if (viewMode === 'direct') {
|
||||||
const currentUserId = JwtUtil.getUserIdFromToken();
|
if (tokenUserId !== null) {
|
||||||
if (currentUserId !== null) {
|
filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
|
||||||
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保过滤后的转发列表有效
|
// 确保过滤后的转发列表有效
|
||||||
if (!filteredForwards || filteredForwards.length === 0) {
|
if (!filteredForwards || filteredForwards.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用数据库中的 inx 字段进行排序
|
// 优先使用数据库中的 inx 字段进行排序
|
||||||
const sortedForwards = [...filteredForwards].sort((a, b) => {
|
const sortedByDb = [...filteredForwards].sort((a, b) => {
|
||||||
const aInx = a.inx ?? 0;
|
const aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
return aInx - bInx;
|
return aInx - bInx;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||||
if (forwardOrder && forwardOrder.length > 0 && sortedForwards.every(f => f.inx === undefined || f.inx === 0)) {
|
if (forwardOrder && forwardOrder.length > 0 && sortedByDb.every(f => f.inx === undefined || f.inx === 0)) {
|
||||||
const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
|
const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
|
||||||
const localSortedForwards: Forward[] = [];
|
const localSortedForwards: Forward[] = [];
|
||||||
|
|
||||||
forwardOrder.forEach(id => {
|
forwardOrder.forEach(id => {
|
||||||
const forward = forwardMap.get(id);
|
const forward = forwardMap.get(id);
|
||||||
if (forward) {
|
if (forward) {
|
||||||
localSortedForwards.push(forward);
|
localSortedForwards.push(forward);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加不在排序列表中的转发(新添加的)
|
// 添加不在排序列表中的转发(新添加的)
|
||||||
filteredForwards.forEach(forward => {
|
filteredForwards.forEach(forward => {
|
||||||
if (!forwardOrder.includes(forward.id)) {
|
if (!forwardOrder.includes(forward.id)) {
|
||||||
localSortedForwards.push(forward);
|
localSortedForwards.push(forward);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return localSortedForwards;
|
return localSortedForwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortedForwards;
|
return sortedByDb;
|
||||||
};
|
}, [forwards, forwardOrder, viewMode, tokenUserId]);
|
||||||
|
|
||||||
|
const sortableForwardIds = useMemo(
|
||||||
|
() => sortedForwards.map(f => f.id).filter(id => id > 0),
|
||||||
|
[sortedForwards]
|
||||||
|
);
|
||||||
|
|
||||||
// 可拖拽的转发卡片组件
|
// 可拖拽的转发卡片组件
|
||||||
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
|
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
|
||||||
@@ -1179,8 +1194,9 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||||
transition: transition || undefined,
|
transition: isDragging ? undefined : transition || undefined,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
willChange: 'transform',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1529,11 +1545,11 @@ export default function ForwardPage() {
|
|||||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={getSortedForwards().map(f => f.id || 0).filter(id => id > 0)}
|
items={sortableForwardIds}
|
||||||
strategy={rectSortingStrategy}
|
strategy={rectSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{getSortedForwards().map((forward) => (
|
{sortedForwards.map((forward) => (
|
||||||
forward && forward.id ? (
|
forward && forward.id ? (
|
||||||
<SortableForwardCard key={forward.id} forward={forward} />
|
<SortableForwardCard key={forward.id} forward={forward} />
|
||||||
) : null
|
) : null
|
||||||
@@ -1606,8 +1622,7 @@ export default function ForwardPage() {
|
|||||||
isInvalid={!!errors.tunnelId}
|
isInvalid={!!errors.tunnelId}
|
||||||
errorMessage={errors.tunnelId}
|
errorMessage={errors.tunnelId}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
isDisabled={isEdit}
|
description={isEdit ? "更改隧道将释放原端口并在新隧道分配端口" : undefined}
|
||||||
description={isEdit ? "编辑时无法修改关联隧道" : undefined}
|
|
||||||
>
|
>
|
||||||
{tunnels.map((tunnel) => (
|
{tunnels.map((tunnel) => (
|
||||||
<SelectItem key={tunnel.id} >
|
<SelectItem key={tunnel.id} >
|
||||||
@@ -2395,4 +2410,4 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Input } from "@heroui/input";
|
import { Input } from "@heroui/input";
|
||||||
@@ -13,20 +13,42 @@ import { Accordion, AccordionItem } from "@heroui/accordion";
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
updateNode,
|
updateNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
getNodeInstallCommand
|
getNodeInstallCommand,
|
||||||
|
updateNodeOrder
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
|
serverIpV4?: string;
|
||||||
|
serverIpV6?: string;
|
||||||
port: string;
|
port: string;
|
||||||
tcpListenAddr?: string;
|
tcpListenAddr?: string;
|
||||||
udpListenAddr?: string;
|
udpListenAddr?: string;
|
||||||
@@ -51,7 +73,9 @@ interface Node {
|
|||||||
interface NodeForm {
|
interface NodeForm {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
serverIp: string;
|
serverHost: string;
|
||||||
|
serverIpV4: string;
|
||||||
|
serverIpV6: string;
|
||||||
port: string;
|
port: string;
|
||||||
tcpListenAddr: string;
|
tcpListenAddr: string;
|
||||||
udpListenAddr: string;
|
udpListenAddr: string;
|
||||||
@@ -61,9 +85,42 @@ interface NodeForm {
|
|||||||
socks: number; // 0 关 1 开
|
socks: number; // 0 关 1 开
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SortableItem = ({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
children: (listeners: any) => any;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||||
|
transition: isDragging ? undefined : transition || undefined,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
willChange: 'transform',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
{children(listeners)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function NodePage() {
|
export default function NodePage() {
|
||||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||||
|
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
|
const [wsConnecting, setWsConnecting] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState('');
|
const [dialogTitle, setDialogTitle] = useState('');
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
@@ -76,7 +133,9 @@ export default function NodePage() {
|
|||||||
const [form, setForm] = useState<NodeForm>({
|
const [form, setForm] = useState<NodeForm>({
|
||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
serverIp: '',
|
serverHost: '',
|
||||||
|
serverIpV4: '',
|
||||||
|
serverIpV6: '',
|
||||||
port: '1000-65535',
|
port: '1000-65535',
|
||||||
tcpListenAddr: '[::]',
|
tcpListenAddr: '[::]',
|
||||||
udpListenAddr: '[::]',
|
udpListenAddr: '[::]',
|
||||||
@@ -96,6 +155,31 @@ export default function NodePage() {
|
|||||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
|
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
const offlineDelayMs = 3000;
|
||||||
|
|
||||||
|
const clearOfflineTimer = (nodeId: number) => {
|
||||||
|
const timer = offlineTimersRef.current.get(nodeId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
offlineTimersRef.current.delete(nodeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNodeOffline = (nodeId: number) => {
|
||||||
|
if (offlineTimersRef.current.has(nodeId)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
offlineTimersRef.current.delete(nodeId);
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((node) => {
|
||||||
|
if (node.id !== nodeId) return node;
|
||||||
|
if (node.connectionStatus === 'offline' && node.systemInfo === null) return node;
|
||||||
|
return { ...node, connectionStatus: 'offline', systemInfo: null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, offlineDelayMs);
|
||||||
|
offlineTimersRef.current.set(nodeId, timer);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNodes();
|
loadNodes();
|
||||||
@@ -112,12 +196,49 @@ export default function NodePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await getNodeList();
|
const res = await getNodeList();
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setNodeList(res.data.map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
|
inx: node.inx ?? 0,
|
||||||
connectionStatus: node.status === 1 ? 'online' : 'offline',
|
connectionStatus: node.status === 1 ? 'online' : 'offline',
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false
|
copyLoading: false,
|
||||||
})));
|
}));
|
||||||
|
|
||||||
|
setNodeList(nodesData);
|
||||||
|
|
||||||
|
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||||
|
const hasDbOrdering = nodesData.some((n) => n.inx !== undefined && n.inx !== 0);
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...nodesData]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((n) => n.id);
|
||||||
|
setNodeOrder(dbOrder);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('node-order');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const existingIds = new Set(nodesData.map((n) => n.id));
|
||||||
|
const validOrder = parsed
|
||||||
|
.map((id: any) => Number(id))
|
||||||
|
.filter((id: number) => existingIds.has(id));
|
||||||
|
|
||||||
|
if (validOrder.length > 0) {
|
||||||
|
setNodeOrder(validOrder);
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNodeOrder(nodesData.map((n) => n.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.msg || '加载节点列表失败');
|
toast.error(res.msg || '加载节点列表失败');
|
||||||
}
|
}
|
||||||
@@ -143,12 +264,15 @@ export default function NodePage() {
|
|||||||
// 构建WebSocket URL,使用axios的baseURL
|
// 构建WebSocket URL,使用axios的baseURL
|
||||||
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||||
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setWsConnecting(true);
|
||||||
websocketRef.current = new WebSocket(wsUrl);
|
websocketRef.current = new WebSocket(wsUrl);
|
||||||
|
|
||||||
websocketRef.current.onopen = () => {
|
websocketRef.current.onopen = () => {
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
setWsConnected(true);
|
||||||
|
setWsConnecting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocketRef.current.onmessage = (event) => {
|
websocketRef.current.onmessage = (event) => {
|
||||||
@@ -166,9 +290,13 @@ export default function NodePage() {
|
|||||||
|
|
||||||
websocketRef.current.onclose = () => {
|
websocketRef.current.onclose = () => {
|
||||||
websocketRef.current = null;
|
websocketRef.current = null;
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
attemptReconnect();
|
attemptReconnect();
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
attemptReconnect();
|
attemptReconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -176,21 +304,27 @@ export default function NodePage() {
|
|||||||
// 处理WebSocket消息
|
// 处理WebSocket消息
|
||||||
const handleWebSocketMessage = (data: any) => {
|
const handleWebSocketMessage = (data: any) => {
|
||||||
const { id, type, data: messageData } = data;
|
const { id, type, data: messageData } = data;
|
||||||
|
const nodeId = Number(id);
|
||||||
|
if (Number.isNaN(nodeId)) return;
|
||||||
|
|
||||||
if (type === 'status') {
|
if (type === 'status') {
|
||||||
setNodeList(prev => prev.map(node => {
|
if (messageData === 1) {
|
||||||
if (node.id == id) {
|
clearOfflineTimer(nodeId);
|
||||||
return {
|
setNodeList((prev) =>
|
||||||
...node,
|
prev.map((node) => {
|
||||||
connectionStatus: messageData === 1 ? 'online' : 'offline',
|
if (node.id !== nodeId) return node;
|
||||||
systemInfo: messageData === 0 ? null : node.systemInfo
|
if (node.connectionStatus === 'online') return node;
|
||||||
};
|
return { ...node, connectionStatus: 'online' };
|
||||||
}
|
})
|
||||||
return node;
|
);
|
||||||
}));
|
} else {
|
||||||
|
// 离线事件做延迟处理,避免短抖动导致频繁闪烁
|
||||||
|
scheduleNodeOffline(nodeId);
|
||||||
|
}
|
||||||
} else if (type === 'info') {
|
} else if (type === 'info') {
|
||||||
|
clearOfflineTimer(nodeId);
|
||||||
setNodeList(prev => prev.map(node => {
|
setNodeList(prev => prev.map(node => {
|
||||||
if (node.id == id) {
|
if (node.id === nodeId) {
|
||||||
try {
|
try {
|
||||||
let systemInfo;
|
let systemInfo;
|
||||||
if (typeof messageData === 'string') {
|
if (typeof messageData === 'string') {
|
||||||
@@ -253,10 +387,12 @@ export default function NodePage() {
|
|||||||
|
|
||||||
// 尝试重新连接
|
// 尝试重新连接
|
||||||
const attemptReconnect = () => {
|
const attemptReconnect = () => {
|
||||||
|
if (reconnectTimerRef.current) return;
|
||||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
initWebSocket();
|
initWebSocket();
|
||||||
}, 3000 * reconnectAttemptsRef.current);
|
}, 3000 * reconnectAttemptsRef.current);
|
||||||
}
|
}
|
||||||
@@ -268,8 +404,13 @@ export default function NodePage() {
|
|||||||
clearTimeout(reconnectTimerRef.current);
|
clearTimeout(reconnectTimerRef.current);
|
||||||
reconnectTimerRef.current = null;
|
reconnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
offlineTimersRef.current.clear();
|
||||||
|
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
|
||||||
if (websocketRef.current) {
|
if (websocketRef.current) {
|
||||||
websocketRef.current.onopen = null;
|
websocketRef.current.onopen = null;
|
||||||
@@ -284,12 +425,6 @@ export default function NodePage() {
|
|||||||
|
|
||||||
websocketRef.current = null;
|
websocketRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNodeList(prev => prev.map(node => ({
|
|
||||||
...node,
|
|
||||||
connectionStatus: 'offline',
|
|
||||||
systemInfo: null
|
|
||||||
})));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -341,29 +476,20 @@ export default function NodePage() {
|
|||||||
return "danger";
|
return "danger";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 验证IP地址格式
|
// IPv4/IPv6 格式验证(仅用于判定地址族)
|
||||||
const validateIp = (ip: string): boolean => {
|
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
if (!ip || !ip.trim()) return false;
|
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||||
|
|
||||||
const trimmedIp = ip.trim();
|
const validateIpv4Literal = (ip: string): boolean => ipv4Regex.test(ip.trim());
|
||||||
|
const validateIpv6Literal = (ip: string): boolean => ipv6Regex.test(ip.trim());
|
||||||
// IPv4格式验证
|
|
||||||
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
// Hostname/domain validation (no scheme/port)
|
||||||
|
const hostnameRegex = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;
|
||||||
// IPv6格式验证
|
const validateHostname = (host: string): boolean => {
|
||||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
const v = host.trim();
|
||||||
|
if (!v) return false;
|
||||||
if (ipv4Regex.test(trimmedIp) || ipv6Regex.test(trimmedIp) || trimmedIp === 'localhost') {
|
if (v === 'localhost') return true;
|
||||||
return true;
|
return hostnameRegex.test(v);
|
||||||
}
|
|
||||||
|
|
||||||
// 验证域名格式
|
|
||||||
if (/^\d+$/.test(trimmedIp)) return false;
|
|
||||||
|
|
||||||
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
||||||
const singleLabelDomain = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}$/;
|
|
||||||
|
|
||||||
return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 验证端口格式:支持 80,443,100-600
|
// 验证端口格式:支持 80,443,100-600
|
||||||
@@ -429,10 +555,25 @@ export default function NodePage() {
|
|||||||
newErrors.name = '节点名称长度不能超过50位';
|
newErrors.name = '节点名称长度不能超过50位';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.serverIp.trim()) {
|
const v4 = form.serverIpV4.trim();
|
||||||
newErrors.serverIp = '请输入服务器IP地址';
|
const v6 = form.serverIpV6.trim();
|
||||||
} else if (!validateIp(form.serverIp.trim())) {
|
const host = form.serverHost.trim();
|
||||||
newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名';
|
|
||||||
|
if (!v4 && !v6 && !host) {
|
||||||
|
const msg = '请至少填写一个 IPv4/IPv6 地址或域名';
|
||||||
|
newErrors.serverIpV4 = msg;
|
||||||
|
newErrors.serverIpV6 = msg;
|
||||||
|
newErrors.serverHost = msg;
|
||||||
|
} else {
|
||||||
|
if (v4 && !validateIpv4Literal(v4)) {
|
||||||
|
newErrors.serverIpV4 = '请输入有效的IPv4地址';
|
||||||
|
}
|
||||||
|
if (v6 && !validateIpv6Literal(v6)) {
|
||||||
|
newErrors.serverIpV6 = '请输入有效的IPv6地址';
|
||||||
|
}
|
||||||
|
if (host && !validateHostname(host)) {
|
||||||
|
newErrors.serverHost = '请输入有效的域名/主机名';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const portValidation = validatePort(form.port);
|
const portValidation = validatePort(form.port);
|
||||||
@@ -458,10 +599,18 @@ export default function NodePage() {
|
|||||||
const handleEdit = (node: Node) => {
|
const handleEdit = (node: Node) => {
|
||||||
setDialogTitle('编辑节点');
|
setDialogTitle('编辑节点');
|
||||||
setIsEdit(true);
|
setIsEdit(true);
|
||||||
|
|
||||||
|
const legacy = (node.serverIp || '').trim();
|
||||||
|
const normalizedV4 = node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : '');
|
||||||
|
const normalizedV6 = node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : '');
|
||||||
|
const normalizedHost = (!normalizedV4 && !normalizedV6 && legacy) ? legacy : '';
|
||||||
|
|
||||||
setForm({
|
setForm({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
serverIp: node.serverIp || '',
|
serverHost: normalizedHost,
|
||||||
|
serverIpV4: normalizedV4,
|
||||||
|
serverIpV6: normalizedV6,
|
||||||
port: node.port || '1000-65535',
|
port: node.port || '1000-65535',
|
||||||
tcpListenAddr: node.tcpListenAddr || '[::]',
|
tcpListenAddr: node.tcpListenAddr || '[::]',
|
||||||
udpListenAddr: node.udpListenAddr || '[::]',
|
udpListenAddr: node.udpListenAddr || '[::]',
|
||||||
@@ -552,8 +701,10 @@ export default function NodePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const apiCall = isEdit ? updateNode : createNode;
|
const apiCall = isEdit ? updateNode : createNode;
|
||||||
const data = {
|
const { serverHost: _serverHost, ...rest } = form;
|
||||||
...form
|
const data = {
|
||||||
|
...rest,
|
||||||
|
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await apiCall(data);
|
const res = await apiCall(data);
|
||||||
@@ -562,20 +713,22 @@ export default function NodePage() {
|
|||||||
setDialogVisible(false);
|
setDialogVisible(false);
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
setNodeList(prev => prev.map(n =>
|
setNodeList(prev => prev.map(n =>
|
||||||
n.id === form.id ? {
|
n.id === form.id ? {
|
||||||
...n,
|
...n,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
serverIp: form.serverIp,
|
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || '',
|
||||||
port: form.port,
|
serverIpV4: form.serverIpV4,
|
||||||
tcpListenAddr: form.tcpListenAddr,
|
serverIpV6: form.serverIpV6,
|
||||||
udpListenAddr: form.udpListenAddr,
|
port: form.port,
|
||||||
interfaceName: form.interfaceName,
|
tcpListenAddr: form.tcpListenAddr,
|
||||||
http: form.http,
|
udpListenAddr: form.udpListenAddr,
|
||||||
tls: form.tls,
|
interfaceName: form.interfaceName,
|
||||||
socks: form.socks
|
http: form.http,
|
||||||
} : n
|
tls: form.tls,
|
||||||
));
|
socks: form.socks
|
||||||
|
} : n
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
loadNodes();
|
loadNodes();
|
||||||
}
|
}
|
||||||
@@ -594,7 +747,9 @@ export default function NodePage() {
|
|||||||
setForm({
|
setForm({
|
||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
serverIp: '',
|
serverHost: '',
|
||||||
|
serverIpV4: '',
|
||||||
|
serverIpV6: '',
|
||||||
port: '1000-65535',
|
port: '1000-65535',
|
||||||
tcpListenAddr: '[::]',
|
tcpListenAddr: '[::]',
|
||||||
udpListenAddr: '[::]',
|
udpListenAddr: '[::]',
|
||||||
@@ -606,6 +761,103 @@ export default function NodePage() {
|
|||||||
setErrors({});
|
setErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || active.id === over.id) return;
|
||||||
|
if (!nodeOrder || nodeOrder.length === 0) return;
|
||||||
|
|
||||||
|
const activeId = Number(active.id);
|
||||||
|
const overId = Number(over.id);
|
||||||
|
if (isNaN(activeId) || isNaN(overId)) return;
|
||||||
|
|
||||||
|
const oldIndex = nodeOrder.indexOf(activeId);
|
||||||
|
const newIndex = nodeOrder.indexOf(overId);
|
||||||
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);
|
||||||
|
setNodeOrder(newOrder);
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('node-order', JSON.stringify(newOrder));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法保存节点排序到localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到数据库
|
||||||
|
try {
|
||||||
|
const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||||
|
const response = await updateNodeOrder({ nodes: nodesToUpdate });
|
||||||
|
if (response.code === 0) {
|
||||||
|
setNodeList((prev) =>
|
||||||
|
prev.map((node) => {
|
||||||
|
const updated = nodesToUpdate.find((n) => n.id === node.id);
|
||||||
|
return updated ? { ...node, inx: updated.inx } : node;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存节点排序到数据库失败:', error);
|
||||||
|
toast.error('保存排序失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据排序顺序获取节点列表
|
||||||
|
const sortedNodes = useMemo((): Node[] => {
|
||||||
|
if (!nodeList || nodeList.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedByDb = [...nodeList].sort((a, b) => {
|
||||||
|
const aInx = a.inx ?? 0;
|
||||||
|
const bInx = b.inx ?? 0;
|
||||||
|
return aInx - bInx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||||
|
if (nodeOrder && nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0)) {
|
||||||
|
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
|
||||||
|
const localSorted: Node[] = [];
|
||||||
|
|
||||||
|
nodeOrder.forEach((id) => {
|
||||||
|
const node = nodeMap.get(id);
|
||||||
|
if (node) localSorted.push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeList.forEach((node) => {
|
||||||
|
if (!nodeOrder.includes(node.id)) {
|
||||||
|
localSorted.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return localSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedByDb;
|
||||||
|
}, [nodeList, nodeOrder]);
|
||||||
|
|
||||||
|
const sortableNodeIds = useMemo(() => sortedNodes.map((n) => n.id), [sortedNodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="px-3 lg:px-6 py-8">
|
<div className="px-3 lg:px-6 py-8">
|
||||||
@@ -626,6 +878,15 @@ export default function NodePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!wsConnected && (
|
||||||
|
<Alert
|
||||||
|
color="warning"
|
||||||
|
variant="flat"
|
||||||
|
description={wsConnecting ? '监控连接中...' : '监控连接已断开,正在重连...'}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 节点列表 */}
|
{/* 节点列表 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -651,18 +912,35 @@ export default function NodePage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
{nodeList.map((node) => (
|
<SortableContext
|
||||||
<Card
|
items={sortableNodeIds}
|
||||||
key={node.id}
|
strategy={rectSortingStrategy}
|
||||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
>
|
||||||
>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{sortedNodes.map((node) => (
|
||||||
|
<SortableItem key={node.id} id={node.id}>
|
||||||
|
{(listeners) => (
|
||||||
|
<Card
|
||||||
|
key={node.id}
|
||||||
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||||
|
>
|
||||||
<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">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
{...listeners}
|
||||||
|
title="拖拽排序"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<Chip
|
<Chip
|
||||||
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
|
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -681,9 +959,24 @@ export default function NodePage() {
|
|||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
<span className="text-default-600 flex-shrink-0">IP</span>
|
<span className="text-default-600 flex-shrink-0">IP</span>
|
||||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||||
<span className="font-mono truncate block" title={node.serverIp.trim()}>
|
{(node.serverIpV4?.trim() || node.serverIpV6?.trim()) ? (
|
||||||
{node.serverIp.trim()}
|
<div className="space-y-0.5">
|
||||||
</span>
|
{node.serverIpV4?.trim() && (
|
||||||
|
<span className="font-mono truncate block" title={node.serverIpV4.trim()}>
|
||||||
|
{node.serverIpV4.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.serverIpV6?.trim() && (
|
||||||
|
<span className="font-mono truncate block" title={node.serverIpV6.trim()}>
|
||||||
|
{node.serverIpV6.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono truncate block" title={node.serverIp.trim()}>
|
||||||
|
{node.serverIp.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
@@ -825,9 +1118,13 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)}
|
||||||
</div>
|
</SortableItem>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 新增/编辑节点对话框 */}
|
{/* 新增/编辑节点对话框 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -853,15 +1150,40 @@ export default function NodePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="服务器IP"
|
label="服务器域名/主机名"
|
||||||
placeholder="请输入服务器IP地址,如: 192.168.1.100 或 example.com"
|
placeholder="例如: node.example.com"
|
||||||
value={form.serverIp}
|
value={form.serverHost}
|
||||||
onChange={(e) => setForm(prev => ({ ...prev, serverIp: e.target.value }))}
|
onChange={(e) => setForm(prev => ({ ...prev, serverHost: e.target.value }))}
|
||||||
isInvalid={!!errors.serverIp}
|
isInvalid={!!errors.serverHost}
|
||||||
errorMessage={errors.serverIp}
|
errorMessage={errors.serverHost}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
|
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="服务器IPv4"
|
||||||
|
placeholder="例如: 203.0.113.10"
|
||||||
|
value={form.serverIpV4}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, serverIpV4: e.target.value }))}
|
||||||
|
isInvalid={!!errors.serverIpV4}
|
||||||
|
errorMessage={errors.serverIpV4}
|
||||||
|
variant="bordered"
|
||||||
|
description="双栈节点组隧道时优先使用 IPv4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="服务器IPv6"
|
||||||
|
placeholder="例如: 2001:db8::10"
|
||||||
|
value={form.serverIpV6}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, serverIpV6: e.target.value }))}
|
||||||
|
isInvalid={!!errors.serverIpV6}
|
||||||
|
errorMessage={errors.serverIpV6}
|
||||||
|
variant="bordered"
|
||||||
|
description="至少填写一个 IPv4/IPv6/域名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="可用端口"
|
label="可用端口"
|
||||||
placeholder="例如: 80,443,1000-65535"
|
placeholder="例如: 80,443,1000-65535"
|
||||||
@@ -1123,4 +1445,4 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Input, Textarea } from "@heroui/input";
|
import { Input, Textarea } from "@heroui/input";
|
||||||
@@ -10,6 +10,24 @@ import { Divider } from "@heroui/divider";
|
|||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createTunnel,
|
createTunnel,
|
||||||
@@ -17,7 +35,8 @@ import {
|
|||||||
updateTunnel,
|
updateTunnel,
|
||||||
deleteTunnel,
|
deleteTunnel,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
diagnoseTunnel
|
diagnoseTunnel,
|
||||||
|
updateTunnelOrder
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface ChainTunnel {
|
interface ChainTunnel {
|
||||||
@@ -30,6 +49,7 @@ interface ChainTunnel {
|
|||||||
|
|
||||||
interface Tunnel {
|
interface Tunnel {
|
||||||
id: number;
|
id: number;
|
||||||
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: number; // 1: 端口转发, 2: 隧道转发
|
type: number; // 1: 端口转发, 2: 隧道转发
|
||||||
inNodeId: ChainTunnel[]; // 入口节点列表
|
inNodeId: ChainTunnel[]; // 入口节点列表
|
||||||
@@ -87,6 +107,7 @@ interface DiagnosisResult {
|
|||||||
export default function TunnelPage() {
|
export default function TunnelPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||||
|
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
@@ -131,7 +152,45 @@ export default function TunnelPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (tunnelsRes.code === 0) {
|
if (tunnelsRes.code === 0) {
|
||||||
setTunnels(tunnelsRes.data || []);
|
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
inx: t.inx ?? 0,
|
||||||
|
}));
|
||||||
|
setTunnels(tunnelsData);
|
||||||
|
|
||||||
|
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||||
|
const hasDbOrdering = tunnelsData.some((t) => t.inx !== undefined && t.inx !== 0);
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...tunnelsData]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((t) => t.id);
|
||||||
|
setTunnelOrder(dbOrder);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('tunnel-order');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const existingIds = new Set(tunnelsData.map((t) => t.id));
|
||||||
|
const validOrder = parsed
|
||||||
|
.map((id: any) => Number(id))
|
||||||
|
.filter((id: number) => existingIds.has(id));
|
||||||
|
|
||||||
|
if (validOrder.length > 0) {
|
||||||
|
setTunnelOrder(validOrder);
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
||||||
}
|
}
|
||||||
@@ -481,6 +540,133 @@ export default function TunnelPage() {
|
|||||||
return { text: '😵 很差', color: 'danger' };
|
return { text: '😵 很差', color: 'danger' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || active.id === over.id) return;
|
||||||
|
if (!tunnelOrder || tunnelOrder.length === 0) return;
|
||||||
|
|
||||||
|
const activeId = Number(active.id);
|
||||||
|
const overId = Number(over.id);
|
||||||
|
if (isNaN(activeId) || isNaN(overId)) return;
|
||||||
|
|
||||||
|
const oldIndex = tunnelOrder.indexOf(activeId);
|
||||||
|
const newIndex = tunnelOrder.indexOf(overId);
|
||||||
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex);
|
||||||
|
setTunnelOrder(newOrder);
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tunnel-order', JSON.stringify(newOrder));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法保存隧道排序到localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到数据库
|
||||||
|
try {
|
||||||
|
const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||||
|
const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate });
|
||||||
|
if (response.code === 0) {
|
||||||
|
setTunnels((prev) =>
|
||||||
|
prev.map((tunnel) => {
|
||||||
|
const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id);
|
||||||
|
return updated ? { ...tunnel, inx: updated.inx } : tunnel;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存隧道排序到数据库失败:', error);
|
||||||
|
toast.error('保存排序失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据排序顺序获取隧道列表
|
||||||
|
const sortedTunnels = useMemo((): Tunnel[] => {
|
||||||
|
if (!tunnels || tunnels.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedByDb = [...tunnels].sort((a, b) => {
|
||||||
|
const aInx = a.inx ?? 0;
|
||||||
|
const bInx = b.inx ?? 0;
|
||||||
|
return aInx - bInx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||||
|
if (tunnelOrder && tunnelOrder.length > 0 && sortedByDb.every((t) => t.inx === undefined || t.inx === 0)) {
|
||||||
|
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
|
||||||
|
const localSorted: Tunnel[] = [];
|
||||||
|
|
||||||
|
tunnelOrder.forEach((id) => {
|
||||||
|
const tunnel = tunnelMap.get(id);
|
||||||
|
if (tunnel) localSorted.push(tunnel);
|
||||||
|
});
|
||||||
|
|
||||||
|
tunnels.forEach((tunnel) => {
|
||||||
|
if (!tunnelOrder.includes(tunnel.id)) {
|
||||||
|
localSorted.push(tunnel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return localSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedByDb;
|
||||||
|
}, [tunnels, tunnelOrder]);
|
||||||
|
|
||||||
|
const sortableTunnelIds = useMemo(() => sortedTunnels.map((t) => t.id), [sortedTunnels]);
|
||||||
|
|
||||||
|
const SortableItem = ({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
children: (listeners: any) => any;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||||
|
transition: isDragging ? undefined : transition || undefined,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
willChange: 'transform',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
{children(listeners)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
||||||
@@ -516,17 +702,24 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{tunnels.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
{tunnels.map((tunnel) => {
|
<SortableContext
|
||||||
|
items={sortableTunnelIds}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{sortedTunnels.map((tunnel) => {
|
||||||
const typeDisplay = getTypeDisplay(tunnel.type);
|
const typeDisplay = getTypeDisplay(tunnel.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={tunnel.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||||
<CardHeader className="pb-2">
|
{(listeners) => (
|
||||||
<div className="flex justify-between items-start w-full">
|
<Card key={tunnel.id} className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
||||||
<div className="flex-1 min-w-0">
|
<CardHeader className="pb-2">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Chip
|
<Chip
|
||||||
color={typeDisplay.color as any}
|
color={typeDisplay.color as any}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -535,13 +728,23 @@ export default function TunnelPage() {
|
|||||||
>
|
>
|
||||||
{typeDisplay.text}
|
{typeDisplay.text}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
{...listeners}
|
||||||
|
title="拖拽排序"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3">
|
<CardBody className="pt-0 pb-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 拓扑结构 */}
|
{/* 拓扑结构 */}
|
||||||
<div className="pt-2 border-t border-divider">
|
<div className="pt-2 border-t border-divider">
|
||||||
@@ -654,10 +857,14 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)}
|
||||||
})}
|
</SortableItem>
|
||||||
</div>
|
);
|
||||||
) : (
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<CardBody className="text-center py-16">
|
<CardBody className="text-center py-16">
|
||||||
@@ -1629,4 +1836,4 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
declare module 'recharts' {
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const LineChart: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const Line: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const XAxis: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const YAxis: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const CartesianGrid: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const Tooltip: React.ComponentType<Record<string, unknown>>;
|
||||||
|
export const ResponsiveContainer: React.ComponentType<Record<string, unknown>>;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -1 +1,10 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_VERSION?: string;
|
||||||
|
readonly VITE_GITHUB_REPO?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user