Compare commits

..

1 Commits

Author SHA1 Message Date
sagitchu 6a1fc2abf2 chore: update AGENTS.md with next release info 2026-03-21 19:42:24 +08:00
63 changed files with 591 additions and 4960 deletions
-1
View File
@@ -67,7 +67,6 @@ go-gost/ss/
.entire/
bin/
tmp/
.worktrees/
*.swp
*.bak
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Tue Mar 24 2026
**Commit:** 8ebde9d
**Generated:** Sat Mar 21 2026
**Commit:** 0767c9c
**Branch:** main
**Tag:** 2.1.9-rc10
**Tag:** 2.1.9-beta13
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
@@ -1,132 +0,0 @@
# Floating Save Button Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a floating save button (FAB) to the config page that appears when configuration changes are detected.
**Architecture:** Inline FAB implementation using framer-motion AnimatePresence for enter/exit animations. Fixed-position circular button with slide-up animation, reusing existing hasChanges state and handleSave function.
**Tech Stack:** React, framer-motion (v11.18.2), shadcn-bridge/heroui Button, Tailwind CSS
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `vite-frontend/src/pages/config.tsx` | Modify | Add FAB imports and component at page bottom |
---
### Task 1: Add framer-motion Imports
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1-5`
- [ ] **Step 1: Add AnimatePresence and motion imports**
Add import statement after existing framer-motion imports (or at top if none exist).
Current imports at line 1-2:
```typescript
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
```
Add new import after line 2:
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
- [ ] **Step 2: Commit import addition**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add framer-motion imports for FAB animation"
```
---
### Task 2: Add FAB Component
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1220-1224` (end of component)
- [ ] **Step 1: Add FAB at end of component (before closing div)**
Locate the end of `ConfigPage` component (line ~1223, the closing `</div>` after all modals).
Insert FAB component before the closing `</div>`:
```tsx
{/* Floating Save Button (FAB) */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
```
- [ ] **Step 2: Run dev server to verify**
```bash
cd vite-frontend && npm run dev
```
Manual verification checklist:
- Open config page at http://localhost:3000/config
- Modify any config field
- Verify FAB appears with slide-up animation
- Click FAB to save
- Verify FAB disappears with slide-down animation after save
- Scroll page and verify FAB stays fixed in viewport corner
- Test on mobile viewport (resize browser or use dev tools)
- [ ] **Step 3: Commit FAB implementation**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add floating save button (FAB) for issue #266"
```
---
## Verification Summary
| Requirement | Verification Method |
|-------------|---------------------|
| FAB hidden by default | Visual: no FAB on page load with no changes |
| FAB appears on change | Visual: modify field → FAB slides up |
| Fixed position | Visual: scroll page → FAB stays in corner |
| Slide-up animation | Visual: observe animation timing/bounce |
| Slide-down on save | Visual: click save → FAB slides down |
| Loading state | Visual: click save → spinner shown during save |
| Mobile compatibility | Visual: resize to mobile viewport → same behavior |
---
## Self-Review Checklist
- [x] Spec coverage: All requirements from design doc covered (imports + FAB component, animation params, button style, interaction behavior)
- [x] No placeholders: All code shown, no TBD/TODO
- [x] Type consistency: SaveIcon (line 45-59), handleSave (line 372-434), hasChanges (line 214), saving (line 213) all exist in config.tsx
@@ -1,520 +0,0 @@
# GitHub 加速地址自定义配置实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速,配置影响全部下载场景。
**Architecture:** 使用现有 `vite_config` 表存储配置,后端 Handler 读取配置替换硬编码,前端复用现有配置项渲染逻辑,安装脚本支持环境变量和交互式询问。
**Tech Stack:** Go 1.24, React/TypeScript, Shell/Bash
---
## 文件结构
| 文件 | 操作 | 说明 |
|------|------|------|
| `go-backend/internal/http/handler/upgrade.go` | 修改 | 移除硬编码,添加配置读取函数 |
| `go-backend/internal/http/handler/mutations.go` | 修改 | `nodeInstall` 函数使用动态配置 |
| `vite-frontend/src/pages/config.tsx` | 修改 | 添加两个新配置项 |
| `install.sh` | 修改 | 支持交互式询问和环境变量 |
| `panel_install.sh` | 修改 | 支持交互式询问和环境变量 |
| `test-install-scripts-proxy.sh` | 新增 | 覆盖代理交互与下载 URL 回归 |
---
## Task 1: 后端 - upgrade.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/upgrade.go`
- [x] **Step 1: 移除硬编码常量,添加配置读取函数**
在 `upgrade.go` 中,移除 `githubProxy` 常量,添加 `getGithubProxyConfig` 函数:
找到第 16-26 行:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
```
替换为:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
defaultGithubProxyEnabled = true
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
)
```
然后在 `releaseChannelLabel` 函数后(约第 71 行之后)添加新函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启加速, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = defaultGithubProxyEnabled
proxyURL = defaultGithubProxyURL
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildGithubDownloadURL 构建 GitHub 下载地址
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
- [x] **Step 2: 修改 nodeUpgrade 函数使用动态配置**
找到第 152-159 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 3: 修改 nodeBatchUpgrade 函数使用动态配置**
找到第 216-223 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 4: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 5: 提交**
```bash
git add go-backend/internal/http/handler/upgrade.go
git commit -m "feat(backend): use configurable github proxy for node upgrades"
```
---
## Task 2: 后端 - mutations.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/mutations.go`
- [x] **Step 1: 修改 nodeInstall 函数使用动态配置**
找到第 456 行:
```go
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
```
替换为:
```go
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
} else {
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret)
}
```
- [x] **Step 2: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 3: 提交**
```bash
git add go-backend/internal/http/handler/mutations.go
git commit -m "feat(backend): use configurable github proxy for node install command"
```
---
## Task 3: 前端 - config.tsx 添加配置项
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [x] **Step 1: 在 CONFIG_ITEMS 数组中添加配置项**
找到第 158 行(`CONFIG_ITEMS` 数组的结束位置):
```go
{
key: "cloudflare_secret_key",
label: "Cloudflare Secret Key",
placeholder: "请输入 Cloudflare Secret Key",
description: "Cloudflare Turnstile 密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
},
];
```
在 `];` 之前添加:
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
- [x] **Step 2: 在缓存键列表中添加新键**
找到第 179-190 行:
```typescript
const configKeys = [
"app_name",
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
];
```
在 `"app_favicon",` 之后添加:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
- [x] **Step 3: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功,无错误
- [x] **Step 4: 提交**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(frontend): add github proxy config settings"
```
---
## Task 4: 安装脚本 - install.sh 修改
**Files:**
- Modify: `install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 28-32 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_flux_agent 函数添加询问**
找到第 211-214 行:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
get_config_params
```
替换为:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add install.sh
git commit -m "feat(script): add configurable github proxy for install.sh"
```
---
## Task 5: 安装脚本 - panel_install.sh 修改
**Files:**
- Modify: `panel_install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 16-20 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_panel 函数添加询问**
找到第 375-378 行:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
check_docker
get_config_params
```
替换为:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
check_docker
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add panel_install.sh
git commit -m "feat(script): add configurable github proxy for panel_install.sh"
```
---
## Task 6: 最终验证和提交
- [x] **Step 1: 验证后端编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功
- [x] **Step 2: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功
- [x] **Step 3: 验证脚本语法**
Run: `bash -n install.sh && bash -n panel_install.sh && bash test-install-scripts-proxy.sh`
Expected: 无语法错误,且脚本代理回归测试通过
- [ ] **Step 4: 推送所有提交**
```bash
git push
```
---
## 验收标准
1. 面板设置页面显示 GitHub 加速配置项
2. 开关关闭后,下载地址直连 GitHub
3. 自定义加速地址后,节点更新和安装命令使用自定义地址
4. 安装脚本支持交互式询问加速配置
5. 面板生成的安装命令包含加速配置环境变量
@@ -1,220 +0,0 @@
# Commercial White-Label Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow users with a valid license key to activate commercial white-label features, enabling them to remove FLVX branding and use their own app name, logos, and footer.
**Architecture:** Backend API handles license validation and stores state (`is_commercial`). Both frontend and backend check this state to conditionally render or allow modifications to brand config.
**Tech Stack:** Go (Backend API), React + Vite (Frontend UI).
---
### Task 1: Backend License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add license request struct**
Add the `licenseActivateRequest` struct in `handler.go`.
```go
type licenseActivateRequest struct {
LicenseKey string `json:"license_key"`
}
```
- [ ] **Step 2: Add `licenseActivate` handler method**
Add the method to validate the key in `handler.go`.
```go
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req licenseActivateRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
key := strings.TrimSpace(req.LicenseKey)
if !strings.HasPrefix(key, "FLVX-") {
response.WriteJSON(w, response.ErrDefault("无效的商业授权码"))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 3: Register the route**
In `handler.go` inside `Register(mux *http.ServeMux)`, add the route.
```go
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add license activation endpoint"
```
### Task 2: Backend Config Update Validation
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add permission check in `updateConfigs`**
In `updateConfigs`, fetch `isCommercial := h.repo.GetConfig("is_commercial")`. Inside the loop, check if the user is trying to update protected keys.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
protectedKeys := map[string]bool{
"app_name": true,
"app_logo": true,
"app_favicon": true,
"hide_footer_brand": true,
}
```
Inside `for k, v := range payload`:
```go
if protectedKeys[key] && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 2: Add permission check in `updateSingleConfig`**
In `updateSingleConfig`, do the same check before calling `normalizeAndValidateConfigValue`.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add authorization check for commercial config keys"
```
### Task 3: Frontend API & Site Config Update
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/config/site.ts`
- [ ] **Step 1: Add `activateLicense` API**
In `vite-frontend/src/api/index.ts`:
```typescript
export const activateLicense = (licenseKey: string) =>
Network.post("/license/activate", { license_key: licenseKey });
```
- [ ] **Step 2: Update `siteConfig` defaults**
In `vite-frontend/src/config/site.ts`, inside `getInitialConfig()`, add properties.
```typescript
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: configCache.get("is_commercial") === "true",
hide_footer_brand: configCache.get("hide_footer_brand") === "true",
```
- [ ] **Step 3: Update `updateSiteConfig`**
In `updateSiteConfig` inside `site.ts`, extract and update `is_commercial` and `hide_footer_brand`.
```typescript
const isCommercial = resolvedConfigMap.is_commercial === "true";
const hideFooterBrand = resolvedConfigMap.hide_footer_brand === "true";
siteConfig.is_commercial = isCommercial;
siteConfig.hide_footer_brand = hideFooterBrand;
```
- [ ] **Step 4: Commit**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/config/site.ts
git commit -m "feat: add frontend api and update site config state for license"
```
### Task 4: Frontend Footer Component Update
**Files:**
- Modify: `vite-frontend/src/components/version-footer.tsx`
- [ ] **Step 1: Conditionally hide "Powered by FLVX"**
In the render block, wrap the `Powered by FLVX` text.
```tsx
{siteConfig.hide_footer_brand !== true && (
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
)}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/components/version-footer.tsx
git commit -m "feat: conditionally hide flvx footer brand"
```
### Task 5: Frontend Settings Page UI Update
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [ ] **Step 1: Add config keys to initialization**
In `getInitialConfigs`, add `"is_commercial"` and `"hide_footer_brand"` to `configKeys`.
- [ ] **Step 2: Add `hide_footer_brand` switch field**
Add it to the `CONFIG_ITEMS` array.
```typescript
{
key: "hide_footer_brand",
label: "隐藏页面底部 FLVX 版权信息",
description: "需商业版授权才能生效",
type: "switch",
},
```
- [ ] **Step 3: Add license activation UI**
Above the System Config Card (near `value="configs"`), add a new `Card` for "商业版授权". You will need a local state `licenseKey` and an `handleActivateLicense` function that calls `activateLicense(licenseKey)` and refetches configs on success.
- [ ] **Step 4: Disable brand settings when not commercial**
In `renderConfigItem`, compute `isDisabled` and pass it to the `<Input>`, `<Switch>`, and `BrandUploading` UI. Update the logic to disable modifications and add a lock icon or a tooltip explaining that a commercial license is required.
```typescript
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
```
- [ ] **Step 5: Commit**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat: ui settings for commercial white-label and license activation"
```
@@ -1,347 +0,0 @@
# Commercial White-Label (Keygen) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Keygen.sh license activation and periodic validation to manage commercial white-label features, replacing the temporary mock logic.
**Architecture:** The backend generates a machine fingerprint, validates the license via the Keygen.sh API, and creates a machine associated with the license. A periodic job verifies the license status to support remote revocation.
**Tech Stack:** Go (Backend API), Keygen.sh API.
---
### Task 1: Generate and Store Machine Fingerprint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `getOrCreateMachineFingerprint` helper function**
Add a helper function in `handler.go` (or a dedicated license file) to get or generate the machine fingerprint. Use `github.com/google/uuid`.
```go
import "github.com/google/uuid"
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
return "", err
}
return newFp, nil
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add machine fingerprint generation"
```
### Task 2: Create Keygen Client Package
**Files:**
- Create: `go-backend/internal/license/keygen.go`
- [ ] **Step 1: Create Keygen client structs and interface**
Create the file and define the request/response structs for Keygen's `/licenses/actions/validate-key` and `/machines` endpoints. Also define an interface for the client.
```go
package license
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type KeygenClient struct {
AccountID string
Token string
HTTPClient *http.Client
}
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
type ActivateMachineRequest struct {
Data struct {
Type string `json:"type"`
Attributes struct {
Fingerprint string `json:"fingerprint"`
} `json:"attributes"`
Relationships struct {
License struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"license"`
} `json:"relationships"`
} `json:"data"`
}
```
- [ ] **Step 2: Implement `ValidateKey`**
Add the `ValidateKey` method.
```go
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
"key": key,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
```
- [ ] **Step 3: Implement `ActivateMachine`**
Add the `ActivateMachine` method.
```go
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
reqBody.Data.Attributes.Fingerprint = fingerprint
reqBody.Data.Relationships.License.Data.Type = "licenses"
reqBody.Data.Relationships.License.Data.ID = licenseID
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusConflict { // 409 usually means fingerprint already exists
return nil // Machine might already be registered
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/license/keygen.go
git commit -m "feat: add keygen.sh api client"
```
### Task 3: Integrate Keygen into License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Update `licenseActivate` logic**
Modify `licenseActivate` to use the Keygen client instead of the mock logic. Note: For this implementation, we will use an environment variable `KEYGEN_ACCOUNT_ID`. We can use `os.Getenv` directly for simplicity, or hardcode a fallback if not present.
```go
import (
"go-backend/internal/license"
"os"
)
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
// ... (keep request parsing)
key := strings.TrimSpace(req.LicenseKey)
if key == "" {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
// Fallback for mock/development if no keygen account configured
if strings.HasPrefix(key, "FLVX-") {
now := time.Now().UnixMilli()
h.repo.UpsertConfig("license_key", key, now)
h.repo.UpsertConfig("is_commercial", "true", now)
response.WriteJSON(w, response.OKEmpty())
return
}
response.WriteJSON(w, response.ErrDefault("系统未配置 Keygen 账号 ID"))
return
}
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "") // Token may be optional for validate-key depending on policy, or can be passed if needed
valResp, err := client.ValidateKey(key)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
// Try to activate machine
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: integrate keygen into license activation endpoint"
```
### Task 4: Add Periodic License Validation Job
**Files:**
- Modify: `go-backend/internal/http/handler/jobs.go`
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `validateLicenseJob` function in `jobs.go`**
Create a new function that performs the background validation.
```go
import "os"
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
return // Skip if not configured
}
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKey(key)
if err != nil {
// Network error or timeout. We implement a grace period by NOT revoking immediately here.
// In a production system, you might count consecutive failures.
// For now, we skip revocation on network errors.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
// We could optionally clear brand configs here, or just let them be disabled in UI
}
}
```
- [ ] **Step 2: Register the job in `RunJobs`**
In `handler.go` or `jobs.go`, wherever the periodic cron jobs are registered (usually `go h.runJobs()`), ensure `validateLicenseJob` is called periodically (e.g., every 12 hours). Look for `h.startCronJobs()` or similar in `handler.go`.
If a central `RunJobs` loop exists in `jobs.go` (like a `for` loop with a `time.Ticker`), add it there. If not, create a simple goroutine in `Register` or `NewHandler`.
*Assuming there's a `startJobs` or `Init` block in `handler.go`:*
```go
// Inside handler initialization or Register:
go func() {
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.validateLicenseJob()
}
}
}()
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/jobs.go go-backend/internal/http/handler/handler.go
git commit -m "feat: add periodic license validation job"
```
@@ -1,162 +0,0 @@
# Floating Save Button Design
**Date:** 2026-04-01
**Issue:** https://github.com/Sagit-chu/flvx/issues/266
**Status:** Approved
## Overview
Add a Floating Action Button (FAB) to the config page (`vite-frontend/src/pages/config.tsx`) that appears when configuration changes are detected, allowing users to save without scrolling to the top.
## Requirements
From Issue #266:
1. **Default hidden**: FAB not visible when no config changes
2. **Show on change**: Auto-display when `hasChanges` becomes true
3. **Fixed position**: Suspended at bottom-right corner, does not scroll with page
4. **Mobile compatible**: Same behavior on desktop and mobile devices
## Design Decisions
### 1. Implementation Approach
**Inline FAB in config.tsx** (not a reusable component)
- Rationale: Current need is limited to config page only
- State management (`hasChanges`, `saving`) already exists in the page
- framer-motion patterns already established in project
- Avoids over-abstraction (YAGNI)
### 2. UI Structure
Position: `fixed bottom-6 right-6` (24px from viewport edges)
Visual layout:
```
┌──────────────────────────────────────┐
│ [页面内容,可滚动] │
│ │
│ [●] │ ← FAB (fixed position)
└──────────────────────────────────────┘
```
### 3. Button Appearance
- Shape: Circular (`w-12 h-12 rounded-full`)
- Color: Primary (matches existing save button)
- Icon: SaveIcon (already defined in config.tsx)
- Shadow: `shadow-lg` for visual hierarchy
- Style: Icon-only (no text label)
### 4. Animation
Using framer-motion with `AnimatePresence`:
| Phase | Properties |
|-------|------------|
| `initial` | `{ y: 100, opacity: 0 }` - starts below viewport |
| `animate` | `{ y: 0, opacity: 1 }` - slides up to position |
| `exit` | `{ y: 100, opacity: 0 }` - slides back down on hide |
Transition config:
```typescript
transition={{ type: "spring", damping: 20, stiffness: 300 }}
```
Spring parameters produce Material Design-like feel: smooth entrance, slight bounce settle.
### 5. Interaction Details
- **Click**: Calls existing `handleSave()` function
- **Loading state**: Button shows Spinner when `saving === true`
- **Hover**: Inherits Button component's primary color hover behavior
- **z-index**: `z-50` (above page content, below modals)
- **Prevent duplicate click**: Button disabled when `saving === true`
## Technical Implementation
### Code Location
File: `vite-frontend/src/pages/config.tsx`
### Required Imports
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
### FAB Component Structure
```tsx
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
```
### Placement
Insert FAB at the end of the component, before the closing `</div>` (after all Cards and Modals).
### Dependencies
- framer-motion: Already installed (v11.18.2)
- Button: Already imported from `@/shadcn-bridge/heroui/button`
- SaveIcon: Already defined in config.tsx
## Behavior Matrix
| State | FAB Visibility | Button Enabled |
|-------|----------------|----------------|
| `hasChanges = false` | Hidden (not rendered) | N/A |
| `hasChanges = true, saving = false` | Visible, animating in | Yes |
| `hasChanges = true, saving = true` | Visible | No (loading) |
| Save success | Hidden (animating out) | N/A |
## Responsive Behavior
No special handling needed. `fixed bottom-6 right-6` works identically on:
- Desktop browsers
- Mobile browsers
- H5/WebView mode
The FAB maintains consistent 24px margin from viewport edges regardless of screen size.
## Edge Cases
1. **Multiple rapid toggles**: AnimatePresence handles gracefully - exit animation completes before new enter animation
2. **Page unload with unsaved changes**: Not addressed in this design (separate concern)
3. **FAB covers existing warning banner**: z-50 places FAB above the warning banner at line 1004-1013
## Testing Checklist
After implementation, verify:
- [ ] FAB appears when any config field is modified
- [ ] FAB slides up from bottom on appearance
- [ ] FAB slides down to bottom on disappearance
- [ ] FAB fixed position during page scroll
- [ ] FAB triggers save on click
- [ ] FAB shows spinner during save
- [ ] FAB disappears after successful save
- [ ] FAB works on mobile viewport
- [ ] FAB does not interfere with Modal dialogs
@@ -1,274 +0,0 @@
# GitHub 加速地址自定义配置设计
**日期**: 2026-04-01
**状态**: 待审核
**作者**: AI Assistant
## 概述
允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速功能。配置后,面板更新节点、生成安装命令以及安装脚本都使用配置的加速地址。
## 背景
当前 `gcode.hostcentral.cc` 硬编码在多个位置:
- `go-backend/internal/http/handler/upgrade.go` - 节点升级下载 URL
- `go-backend/internal/http/handler/mutations.go` - 节点安装命令生成
- `install.sh` - 节点安装脚本
- `panel_install.sh` - 面板安装脚本
用户无法自定义加速地址或关闭加速功能。
## 目标
1. 面板设置中支持配置加速开关和加速地址
2. 配置影响全部下载场景(面板端 + 安装脚本)
3. 安装脚本支持交互式询问加速配置
4. 面板生成的安装命令自动嵌入加速配置
## 影响范围
### 后端
- `go-backend/internal/http/handler/upgrade.go`
- `go-backend/internal/http/handler/mutations.go`
### 前端
- `vite-frontend/src/pages/config.tsx`
- `vite-frontend/src/config/site.ts`(缓存配置键)
### 安装脚本
- `install.sh`
- `panel_install.sh`
## 详细设计
### 1. 数据存储
使用现有 `vite_config` 表存储两个配置项:
| name | value | 说明 |
|------|-------|------|
| `github_proxy_enabled` | `"true"` / `"false"` | 是否开启加速,默认 `"true"` |
| `github_proxy_url` | URL 字符串 | 加速地址,默认 `"https://gcode.hostcentral.cc"` |
### 2. 后端 Handler 修改
#### upgrade.go
移除硬编码常量,新增辅助函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = true // 默认开启
proxyURL = "https://gcode.hostcentral.cc" // 默认地址
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildDownloadURL 构建下载地址
func (h *Handler) buildDownloadURL(version, arch string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("https://github.com/%s/releases/download/%s/gost-%s", githubRepo, version, arch)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
修改 `nodeUpgrade` 和 `nodeBatchUpgrade` 使用动态配置。
#### mutations.go
修改 `getNodeInstallCmd` 函数(约第 440-456 行):
```go
func (h *Handler) getNodeInstallCmd(w http.ResponseWriter, r *http.Request) {
// ... 现有逻辑 ...
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf(
"curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret,
)
} else {
cmd = fmt.Sprintf(
"curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret,
)
}
response.WriteJSON(w, response.OK(cmd))
}
```
### 3. 前端修改
#### config.tsx
在 `CONFIG_ITEMS` 数组中添加配置项(约第 87-158 行之后):
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
在 `getInitialConfigs` 函数的 `configKeys` 数组中添加缓存键:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
### 4. 安装脚本修改
#### install.sh
在脚本开头添加配置变量和环境变量读取:
```bash
# 镜像加速配置(可由面板传入)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
```
修改 `maybe_proxy_url` 函数:
```bash
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}" # 移除末尾斜杠
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
```
在 `install_flux_agent` 函数开头添加交互式询问:
```bash
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
if [[ -z "$PROXY_ENABLED" ]]; then
echo ""
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N) PROXY_ENABLED="false" ;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
;;
esac
fi
# ... 现有安装逻辑 ...
}
```
#### panel_install.sh
类似修改,在 `install_panel` 函数开头添加询问逻辑。
### 5. 配置缓存
#### site.ts
在配置缓存键列表中添加新键(如果需要前端缓存加速配置)。
## 默认行为
- `github_proxy_enabled`: 默认 `"true"`(开启加速)
- `github_proxy_url`: 默认 `"https://gcode.hostcentral.cc"`
## 测试要点
1. **后端 API 测试**:
- 未配置时使用默认值
- 配置后正确读取并应用
- 关闭加速后直连 GitHub
2. **前端 UI 测试**:
- Switch 开关正确切换
- 关闭加速时隐藏地址输入框
- 保存配置后正确持久化
3. **安装脚本测试**:
- 交互式询问正常工作
- 环境变量传入时跳过询问
- 加速关闭时直连 GitHub
4. **集成测试**:
- 面板生成安装命令正确包含加速配置
- 节点升级下载使用配置的加速地址
## 风险与缓解
| 风险 | 缓解措施 |
|------|----------|
| 用户输入无效加速地址 | 后端验证 URL 格式,前端添加格式提示 |
| 旧版本安装脚本不兼容 | 保持 `maybe_proxy_url` 函数签名不变,仅修改内部逻辑 |
| 配置缺失时行为不一致 | 在 `getGithubProxyConfig` 中提供合理的默认值 |
## 任务清单
- [ ] 后端:upgrade.go 修改
- [ ] 后端:mutations.go 修改
- [ ] 前端:config.tsx 添加配置项
- [ ] 脚本:install.sh 修改
- [ ] 脚本:panel_install.sh 修改
- [ ] 测试:验证功能正常
@@ -1,49 +0,0 @@
# FLVX 商业版白标授权功能设计方案
## 1. 目标
通过在设置面板中引入商业版激活码(License Key),允许已授权的用户去除前端页面的 FLVX 品牌标识,并使用自己的 App Name、Logo、Favicon 和隐藏版权信息,从而实现“白标”定制。
## 2. 功能范围
* **授权校验(服务端)**:提供一个激活码输入与验证的接口。初始版本采用**在线 Mock 验证**,后续可通过替换验证服务器地址实现真实的在线发卡与吊销逻辑。
* **配置存储(服务端)**:一旦授权成功,在数据库(如 `vite_config` 或现有的配置表)中记录授权状态(例如 `license_key`、`is_commercial` 等),并放开商业白标相关字段的写入权限(`app_name`, `app_logo`, `app_favicon`, `hide_footer_brand`)。
* **权限拦截(服务端)**:拦截未授权用户的请求,禁止他们更新相关的品牌字段。
* **前端 UI(客户端)**:
* 在配置页面(或单独的“授权/个性化” Tab)提供激活码输入框。
* 如果未激活:界面仅展示默认品牌配置,并提示“需要商业授权以解锁自定义品牌”。
* 如果已激活:展示站名、Logo、Favicon 的上传和替换表单,提供隐藏“Powered by FLVX”脚标的开关。
## 3. 架构设计
### 3.1 数据库/配置结构
扩展配置系统中的以下字段:
* `license_key` (String):存储用户激活的商业版密钥。
* `is_commercial` (String/Boolean):标识是否为合法的商业授权状态("true" 或 "false")。
* `hide_footer_brand` (String/Boolean):是否隐藏底部的 FLVX 信息。
注意:现有的 `app_name`, `app_logo`, `app_favicon` 字段将收紧修改权限。
### 3.2 服务端 API 变更
* **新增 API `POST /api/license/activate` (或将逻辑集成到现有配置修改接口)**:
* 接收 `{ "license_key": "FLVX-xxxx" }`。
* **Mock 逻辑**:如果是 `FLVX-` 开头则视为合法。
* 合法则更新系统配置,设置 `license_key` 并将状态标为 `is_commercial: "true"`。
* **修改 API 权限校验(如保存系统设置的接口)**:
* 当接收到更新 `app_name`、`app_logo`、`app_favicon`、`hide_footer_brand` 的请求时,检查当前系统中的 `is_commercial` 状态。
* 如果未授权且尝试修改白标字段,返回错误(如 `403 Forbidden`)提示需要商业授权。
### 3.3 前端设计
* **授权卡片**:在全局设置(Settings / Config)页加入「商业版授权」或「个性化」区块。
* **表单按需显示**:使用配置中的 `is_commercial === "true"` 来控制相关表单组件的展示:
* 如果未授权,白标字段(Logo、Favicon、App Name、Hide Footer)不可修改(呈 Disabled)或覆盖了一层“锁”图标。
* 底部 Footer 组件读取 `hide_footer_brand === "true"` 决定是否渲染 `Powered by FLVX`。
* **全局状态同步**:当用户激活或上传完 Logo 后,通过现有的 `syncLogo` / `syncFavicon` 等机制全局刷新外观。
## 4. 安全与降级
* **本地缓存失效**:如果后台在线验证服务器(未来)判断该 key 被吊销,可以在后续获取 config 的接口中重置白标配置为空,强制回退到默认 FLVX 主题。
* **接口防绕过**:所有跟商业字段相关的变更,必须经过后端 API 的鉴权,确保纯前端绕过是无效的。
## 5. 测试策略
1. **输入非法激活码**,提示错误,白标设置项仍被锁定。
2. **输入合法激活码 (`FLVX-...`)**,提示成功,白标设置项解锁。
3. **成功后上传 Logo 和修改站名**,刷新页面,前端应正常应用新配置且没有 FLVX 标记。
4. **接口测试**:在未授权状态下,尝试强行通过 API 更新 `app_logo`,接口应返回权限不足。
@@ -1,59 +0,0 @@
# FLVX 商业版 Keygen.sh 授权集成设计方案
## 1. 目标
使用 [Keygen.sh](https://keygen.sh/) 替换当前 FLVX 中基于 Mock 的商业版授权验证逻辑。通过接入 Keygen.sh,实现安全、可控的许可证分发、设备绑定(防止一码多用)、定期验证以及远程吊销功能,为 FLVX 的商业化白标功能提供生产级支持。
## 2. Keygen.sh 核心概念映射
* **Account (账户)**:您在 Keygen 注册的商户账号。
* **Product (产品)**:在 Keygen 中创建一个名为 `FLVX Panel` 的产品。
* **Policy (策略)**:定义授权规则。例如,创建一个 `White-Label Policy`,限制每个 License 只能绑定 **1 个 Machine**(即一个 FLVX 面板实例),并可配置有效期(如按年订阅或永久有效)。
* **License (许可证)**:发给客户的激活码(Key),格式可自定义(如 `FLVX-XXXX-XXXX`)。
* **Machine (机器/设备)**:运行 FLVX 的具体服务器或面板实例。为了防止一码多开,FLVX 激活时需要向 Keygen 注册一台 Machine。
## 3. 架构设计与集成流程
### 3.1 唯一设备标识 (Machine Fingerprint)
为了在 Keygen 中标识不同的 FLVX 面板,FLVX 后端需要生成并持久化一个唯一的机器指纹(Fingerprint)。
* **生成时机**:FLVX 首次启动或首次激活时,生成一个 UUID v4。
* **存储**:保存在数据库 `vite_config` 表中,键名为 `machine_fingerprint`。
### 3.2 激活流程 (License Activation)
当用户在前端输入激活码并点击“激活”时:
1. **FLVX 后端验证 Key**:调用 Keygen API `POST /v1/accounts/{account}/licenses/actions/validate-key`,传入 `key`。
2. **检查 License 状态**:如果返回 `valid: true`,说明 License 合法且未过期。
3. **激活 Machine (设备绑定)**:
* 调用 Keygen API `POST /v1/accounts/{account}/machines`。
* 关联刚才验证的 `licenseId`,并传入 FLVX 的 `machine_fingerprint`。
* *异常处理*:如果该 License 已绑定了其他 Machine(达到 Policy 上限),Keygen 会报错,FLVX 后端需返回“该授权码已在其他设备使用”。
4. **持久化状态**:激活成功后,在本地数据库保存 `license_key`、`is_commercial: "true"`,以及从 Keygen 返回的额外信息(如过期时间 `license_expiry`)。
### 3.3 定期心跳与验证 (Periodic Validation)
为了防止用户激活后断网或通过修改数据库绕过,以及实现**远程吊销**:
* **定时任务**:FLVX 后端增加一个后台协程(如每天运行一次,或每 12 小时运行一次)。
* **验证逻辑**:调用 Keygen API 验证当前的 `license_key` 和 `machine_fingerprint`。
* **吊销/过期处理**:如果 Keygen 明确返回 License 已吊销(Suspended/Revoked/Banned)或已过期,或者当前 Machine 不再属于该 License,FLVX 后端需将 `is_commercial` 强制设为 `"false"`,并清空本地缓存,恢复官方品牌展示。
* **宽限期 (Grace Period)**:考虑到用户服务器可能偶尔网络不通,如果请求 Keygen 超时或失败,不应立刻吊销。可设置一个宽限期(如连续 3 天请求失败才降级)。
## 4. 后端 API 改造计划 (`go-backend`)
### 4.1 新增环境变量/配置
* `KEYGEN_ACCOUNT_ID`: 您的 Keygen 账户 ID(打包时可硬编码,或作为全局环境变量)。
* (可选)`KEYGEN_PRODUCT_TOKEN` 或仅使用 License Key 进行验证(取决于 Keygen 验证方式的选择,推荐直接使用 License Key 进行无状态验证)。
### 4.2 改造 `/api/v1/license/activate`
* 引入 HTTP 客户端向 `api.keygen.sh` 发起请求。
* 实现上述提到的 Validate Key 和 Activate Machine 两步走逻辑。
* 返回具体的错误信息给前端(例如:“授权码不存在”、“授权码已过期”、“激活设备数达上限”)。
## 5. 前端改造计划 (`vite-frontend`)
前端在目前的 UI 基础上几乎不需要大改,只需配合后端的增强:
1. **展示过期时间**:如果后端返回了 `license_expiry`,可以在“商业版授权”卡片中展示“授权有效期至:YYYY-MM-DD”。
2. **错误提示优化**:透传后端返回的 Keygen 验证错误,给予用户明确的指引。
3. **解绑/停用功能(可选)**:未来可增加“停用授权”按钮,调用后端接口在 Keygen 中删除 Machine 绑定,以便用户将 License 迁移到新的服务器。
## 6. 实施步骤建议
1. 在 Keygen.sh 注册账号,创建 Product 和 Policy,生成测试用的 License Key。
2. 在 FLVX 的 `go-backend` 中新建一个 `pkg/keygen` 或 `internal/license` 包,封装 Keygen API 的调用(Validate, Activate Machine)。
3. 修改现有的 `licenseActivate` 接口,接入真正的验证逻辑。
4. 添加定期验证的 Cron Job。
5. 测试激活、吊销、过期、断网等各种场景。
+5 -5
View File
@@ -5,17 +5,16 @@ go 1.24.0
toolchain go1.24.4
require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.3
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.37.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -29,8 +28,9 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.29.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
)
@@ -280,16 +280,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
// If the limiter push fails because the node is offline, skip it with a warning
if isNodeOfflineOrTimeoutError(err) {
node, _ := h.getNodeRecord(fp.NodeID)
nodeName := fmt.Sprintf("%d", fp.NodeID)
if node != nil && strings.TrimSpace(node.Name) != "" {
nodeName = strings.TrimSpace(node.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
continue
}
return nil, err
}
}
@@ -318,12 +308,6 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
warnings = append(warnings, warning)
}
}
// When a node is offline, skip it with a warning instead of failing.
// This lets users modify forward rules even when some entry nodes are down.
if err != nil && isNodeOfflineOrTimeoutError(err) {
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
continue
}
if err != nil {
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
@@ -1577,11 +1561,10 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
}
var serviceAddr string
if bindIP != "" {
trimmedBindIP := strings.TrimSpace(bindIP)
if _, _, err := net.SplitHostPort(trimmedBindIP); err == nil {
serviceAddr = processServerAddress(trimmedBindIP)
if strings.Contains(bindIP, ":") {
serviceAddr = processServerAddress(bindIP)
} else {
serviceAddr = processServerAddress(net.JoinHostPort(strings.Trim(trimmedBindIP, "[]"), strconv.Itoa(port)))
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port))
}
} else {
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port))
@@ -421,63 +421,6 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
}
}
func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
tests := []struct {
name string
bindIP string
port int
wantAddr string
}{
{
name: "pure ipv6 without port",
bindIP: "2001:db8::1",
port: 22000,
wantAddr: "[2001:db8::1]:22000",
},
{
name: "bracketed ipv6 without port",
bindIP: "[2001:db8::2]",
port: 22001,
wantAddr: "[2001:db8::2]:22001",
},
{
name: "bracketed ipv6 with port",
bindIP: "[2001:db8::3]:8080",
port: 55555,
wantAddr: "[2001:db8::3]:8080",
},
{
name: "ipv6 link-local with zone",
bindIP: "fe80::1%eth0",
port: 22002,
wantAddr: "[fe80::1%eth0]:22002",
},
{
name: "ipv6 localhost",
bindIP: "::1",
port: 22003,
wantAddr: "[::1]:22003",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != tt.wantAddr {
t.Fatalf("expected addr %q, got %q", tt.wantAddr, addr)
}
}
})
}
}
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
tests := []struct {
name string
@@ -345,39 +345,21 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
}
}
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("expected v6 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("expected v4 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
// 真正不兼容:两个节点都没有任何 IP
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for nodes with no IP addresses")
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
}
}
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
}
}
@@ -22,7 +22,6 @@ type userTunnelPolicy struct {
OutFlow int64
ExpTime int64
Status int
Num int
}
type gostConfigSnapshot struct {
@@ -364,16 +363,6 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return err
}
if user.Num > 0 {
currentForwardCount, err := h.repo.CountActiveForwardsByUser(userID)
if err != nil {
return err
}
if currentForwardCount >= int64(user.Num) {
return errors.New("转发数量已达上限")
}
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
if err != nil {
return err
@@ -403,16 +392,6 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("该隧道流量已超额,禁止开启转发")
}
if policy.Num > 0 {
currentTunnelForwardCount, err := h.repo.CountActiveForwardsByUserTunnel(userID, tunnelID)
if err != nil {
return err
}
if currentTunnelForwardCount >= int64(policy.Num) {
return errors.New("该隧道转发数量已达上限")
}
}
return nil
}
@@ -463,7 +442,7 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
ExpTime: ut.ExpTime, Status: ut.Status,
}, nil
}
+1 -129
View File
@@ -19,14 +19,12 @@ import (
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/license"
"go-backend/internal/metrics"
"go-backend/internal/security"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
"github.com/google/uuid"
)
type Handler struct {
repo *repo.Repository
jwtSecret string
@@ -48,8 +46,6 @@ type Handler struct {
qualityProber *tunnelQualityProber
}
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -70,10 +66,6 @@ type configSingleRequest struct {
Value string `json:"value"`
}
type licenseActivateRequest struct {
LicenseKey string `json:"license_key"`
}
type changePasswordRequest struct {
NewUsername string `json:"newUsername"`
CurrentPassword string `json:"currentPassword"`
@@ -143,7 +135,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
@@ -795,85 +786,6 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
return "", err
}
return newFp, nil
}
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req licenseActivateRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
key := strings.TrimSpace(req.LicenseKey)
if key == "" {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
// Needs machine activation
client.Token = key
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
// Translate specific error messages or log them
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
// we can consider the license valid for our simple usecase
} else {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -890,14 +802,6 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
protectedKeys := map[string]bool{
"app_name": true,
"app_logo": true,
"app_favicon": true,
"hide_footer_brand": true,
}
now := time.Now().UnixMilli()
for k, v := range payload {
key := strings.TrimSpace(k)
@@ -905,11 +809,6 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
continue
}
if protectedKeys[key] && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
value, err := normalizeAndValidateConfigValue(key, v)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -942,12 +841,6 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
value, err := normalizeAndValidateConfigValue(name, req.Value)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -993,32 +886,11 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
}
return pngDataURLPrefix + payload, nil
case monitorTunnelQualityEnabledConfigKey:
normalized := strings.TrimSpace(strings.ToLower(value))
switch normalized {
case "true", "false":
return normalized, nil
default:
return "", fmt.Errorf("隧道质量检测开关配置值无效")
}
default:
return value, nil
}
}
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
if h == nil || h.repo == nil {
return true
}
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
if err != nil || cfg == nil {
return true
}
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
+3 -53
View File
@@ -3,8 +3,6 @@ package handler
import (
"context"
"time"
"go-backend/internal/license"
)
func (h *Handler) StartBackgroundJobs() {
@@ -20,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(7)
h.jobsWG.Add(6)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -29,52 +27,6 @@ func (h *Handler) StartBackgroundJobs() {
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
go h.runValidateLicenseJob(ctx)
}
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
defer h.jobsWG.Done()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.validateLicenseJob()
}
}
}
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
// Network error or timeout. Grace period by not revoking immediately here.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
}
}
func (h *Handler) StopBackgroundJobs() {
@@ -114,11 +66,9 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
return
if h.qualityProber != nil {
h.qualityProber.Start(ctx)
}
h.qualityProber.Start(ctx)
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
@@ -71,7 +71,6 @@ type monitorNodeListItem struct {
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
Version string `json:"version"`
UpdatedTime int64 `json:"updatedTime"`
}
@@ -101,7 +100,6 @@ func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request)
Inx: n.Inx,
Name: n.Name,
Status: n.Status,
Version: n.Version.String,
UpdatedTime: updated,
})
}
@@ -245,7 +243,6 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
ChainDetails: q.ChainDetails,
})
}
response.WriteJSON(w, response.OK(snapshots))
+32 -237
View File
@@ -453,16 +453,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
} else {
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret)
}
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
@@ -778,7 +769,14 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
ipPreference := asString(req["ipPreference"])
localDomain := h.federationLocalDomain()
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
tx := h.repo.BeginTx()
if tx.Error != nil {
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
return
}
defer func() { tx.Rollback() }()
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
@@ -797,14 +795,6 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
applyTunnelPortsToRequest(req, runtimeState)
tx := h.repo.BeginTx()
if tx.Error != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.UpdateTunnelTx(
tx,
id,
@@ -1112,113 +1102,24 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
if err != nil {
continue
}
referencePort := pickForwardPortFromRecords(oldPorts)
if referencePort <= 0 {
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
continue
}
// Build a map of existing node → port/inIP from old records.
oldPortByNode := make(map[int64]forwardPortRecord)
for _, fp := range oldPorts {
if fp.NodeID > 0 {
oldPortByNode[fp.NodeID] = fp
var entries []forwardPortReplaceEntry
if allowInIP {
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
} else {
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
}
}
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
// Existing entry node: keep its current port.
inIP := existing.InIP
if !allowInIP {
inIP = ""
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
continue
}
// New entry node: try to follow the reference port.
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
inIP := ""
if allowInIP {
// For single-entry tunnels, try to preserve inIP from old records.
for _, fp := range oldPorts {
if strings.TrimSpace(fp.InIP) != "" {
inIP = fp.InIP
break
}
}
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
}
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
}
}
// resolvePortForNewEntryNode determines the port for a forward on a newly added
// entry node. It tries to reuse referencePort (from existing entries); if that
// port is out of range or already occupied, it picks a random available port
// for this specific node.
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
node, err := h.getNodeRecord(nodeID)
if err != nil {
return referencePort
}
// Check if referencePort is within the node's allowed range.
if validateLocalNodePort(node, referencePort) == nil &&
validateRemoteNodePort(node, referencePort) == nil {
// In range — check availability.
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
if occErr == nil && !occupied {
return referencePort
}
}
// referencePort doesn't work for this node; pick a random one.
newPort := h.pickRandomPortForNode(nodeID)
if newPort > 0 {
return newPort
}
return referencePort // last resort fallback
}
// pickRandomPortForNode picks a random available port from a single node's
// port range, excluding ports already occupied by other forwards or chains.
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
portRange, err := h.repo.GetNodePortRange(nodeID)
if err != nil {
return 0
}
if portRange == "" {
portRange = "1000-65535"
}
nodePorts, err := parsePorts(portRange)
if err != nil || len(nodePorts) == 0 {
return 0
}
used, err := h.getUsedPorts(nodeID)
if err != nil {
return 0
}
var available []int
for _, p := range nodePorts {
if !used[p] {
available = append(available, p)
}
}
if len(available) == 0 {
return 0
}
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
return available[idx.Int64()]
}
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1567,12 +1468,6 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
if lookupErr != nil {
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
return
}
h.cleanupForwardsForUserTunnel(userID, tunnelID)
if err := h.repo.DeleteUserTunnel(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -2307,24 +2202,13 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
nd, ndErr := h.getNodeRecord(nid)
if ndErr != nil {
portRangeErr = ndErr
portRangeOk = false
break
continue
}
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
if validateErr := validateLocalNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
if validateErr := h.validateForwardPortAvailability(nd, p, id); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
}
if !portRangeOk {
fail++
@@ -2510,18 +2394,14 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
_ = h.syncPermissionsByUserGroup(req.GroupID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -2565,12 +2445,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
var revokedPairs []repo.RevokedUserTunnelPair
if exists {
var revokeErr error
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
@@ -2579,9 +2456,6 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2744,11 +2618,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
if !isRemote {
var err error
if excludeTunnelID > 0 {
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
} else {
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
}
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
if err != nil {
return nil, err
}
@@ -2783,11 +2653,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
if !isRemote {
var err error
if excludeTunnelID > 0 {
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
} else {
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
}
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
if err != nil {
return nil, err
}
@@ -2809,22 +2675,6 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
}
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
// node IDs that already belong to the tunnel so we can tolerate offline
// nodes that the user is keeping or removing, while still rejecting newly
// added offline nodes.
existingNodeIDs := make(map[int64]struct{})
if excludeTunnelID > 0 {
var existIDs []int64
if err := tx.Model(&model.ChainTunnel{}).
Where("tunnel_id = ?", excludeTunnelID).
Pluck("node_id", &existIDs).Error; err == nil {
for _, eid := range existIDs {
existingNodeIDs[eid] = struct{}{}
}
}
}
seen := make(map[int64]struct{}, len(nodeIDs))
for _, nodeID := range nodeIDs {
if _, ok := seen[nodeID]; ok {
@@ -2843,12 +2693,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
return nil, errors.New("节点不存在")
}
if node.IsRemote != 1 && node.Status != 1 {
// For tunnel updates, allow offline nodes that already belong to the
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
_, isExisting := existingNodeIDs[nodeID]
if excludeTunnelID <= 0 || !isExisting {
return nil, errors.New("部分节点不在线")
}
return nil, errors.New("部分节点不在线")
}
state.Nodes[nodeID] = node
}
@@ -3248,6 +3093,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -3257,7 +3103,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
@@ -3271,8 +3117,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
nextTargets = state.ChainHops[i+1]
}
for _, chainNode := range hop {
node := state.Nodes[chainNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
@@ -3280,18 +3125,12 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdServices = append(createdServices, chainNode.NodeID)
@@ -3299,15 +3138,11 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, outNode := range state.OutNodes {
node := state.Nodes[outNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
createdServices = append(createdServices, outNode.NodeID)
@@ -3398,13 +3233,6 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
return false
}
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
// is unreachable (offline or timed out), matching the same patterns used by
// shouldDeferTunnelRuntimeApplyError.
func isNodeOfflineOrTimeoutError(err error) bool {
return shouldDeferTunnelRuntimeApplyError(err)
}
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
fromNode := nodes[fromNodeID]
if fromNode == nil {
@@ -3531,7 +3359,6 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
}
}
default:
// 同版本优先
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
@@ -3542,17 +3369,6 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
return host, nil
}
}
// 跨版本支持:v6入v4出 / v4入v6出
if fromV6 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
if fromV4 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
}
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
}
@@ -3661,7 +3477,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
port := asInt(n["port"], 0)
if port <= 0 {
var pickErr error
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
if pickErr != nil {
return pickErr
}
@@ -3691,7 +3507,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
port := asInt(n["port"], 0)
if port <= 0 {
var pickErr error
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
if pickErr != nil {
return pickErr
}
@@ -4095,27 +3911,6 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
return nil
}
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
// specific user+tunnel pair. It notifies nodes to remove the runtime services
// first, then deletes the DB records. This is best-effort: individual failures
// do not abort the overall cleanup so that remaining forwards are still cleaned.
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
if userID <= 0 || tunnelID <= 0 {
return
}
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
if err != nil || len(forwards) == 0 {
return
}
for i := range forwards {
f := &forwards[i]
if f.Status == 1 {
_ = h.controlForwardServices(f, "DeleteService", true)
}
_ = h.deleteForwardByID(f.ID)
}
}
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
if speedID == nil {
return nil, nil
@@ -2,7 +2,6 @@ package handler
import (
"context"
"encoding/json"
"log"
"sync"
"sync/atomic"
@@ -20,17 +19,6 @@ const (
tunnelQualityReportInterval = 30 * time.Second // DB save interval
)
type TunnelQualityHop struct {
FromNodeID int64 `json:"fromNodeId"`
FromNodeName string `json:"fromNodeName"`
ToNodeID int64 `json:"toNodeId"`
ToNodeName string `json:"toNodeName"`
Latency float64 `json:"latency"`
Loss float64 `json:"loss"`
TargetIP string `json:"targetIp,omitempty"`
TargetPort int `json:"targetPort,omitempty"`
}
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
type tunnelQualitySnapshot struct {
TunnelID int64 `json:"tunnelId"`
@@ -41,7 +29,6 @@ type tunnelQualitySnapshot struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
ChainDetails string `json:"chainDetails,omitempty"`
// internal fields for db reporting
lastDBWrite int64 `json:"-"`
@@ -61,8 +48,11 @@ type tunnelQualityProber struct {
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
@@ -76,10 +66,6 @@ func (p *tunnelQualityProber) Start(ctx context.Context) {
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
if p == nil || p.cancel == nil {
return
}
p.cancel()
}
@@ -120,20 +106,8 @@ func (p *tunnelQualityProber) loop() {
}
}
func (p *tunnelQualityProber) isEnabled() bool {
if p == nil || p.handler == nil {
return true
}
return p.handler.isTunnelQualityMonitoringEnabled()
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
if !p.isEnabled() {
return
}
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
@@ -152,10 +126,6 @@ func (p *tunnelQualityProber) maybePrune() {
}
func (p *tunnelQualityProber) probeAll() {
if !p.isEnabled() {
return
}
// Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return
@@ -228,7 +198,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
@@ -254,85 +224,28 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
var hops []TunnelQualityHop
var totalLat float64
remainingSuccessProb := 1.0
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
nodesInPath = append(nodesInPath, inNodes[0])
for _, midGroup := range midNodesGrouped {
if len(midGroup) > 0 {
nodesInPath = append(nodesInPath, midGroup[0])
}
}
nodesInPath = append(nodesInPath, outNodes[0])
for i := 0; i < len(nodesInPath)-1; i++ {
source := nodesInPath[i]
target := nodesInPath[i+1]
hop := TunnelQualityHop{
FromNodeID: source.NodeID,
FromNodeName: source.NodeName,
ToNodeID: target.NodeID,
ToNodeName: target.NodeName,
}
targetNode, nodeErr := h.getNodeRecord(target.NodeID)
if nodeErr != nil || targetNode == nil {
snap.ErrorMessage = "节点 " + target.NodeName + " 不可用"
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
break
}
fromNode, _ := h.getNodeRecord(source.NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, target.Port, ipPreference, target.ConnectIP)
if resolveErr != nil {
snap.ErrorMessage = "解析节点 " + target.NodeName + " 失败: " + resolveErr.Error()
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
break
}
hop.TargetIP = targetIP
hop.TargetPort = targetPort
lat, loss, err := p.tcpPingNode(source.NodeID, targetIP, targetPort, options)
if err == nil {
hop.Latency = lat
hop.Loss = loss
totalLat += lat
remainingSuccessProb *= (1.0 - loss/100.0)
hops = append(hops, hop)
} else {
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
if snap.ErrorMessage == "" {
snap.ErrorMessage = err.Error()
// Entry → Exit
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
if nodeErr == nil && targetNode != nil {
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
if resolveErr == nil {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
if err == nil {
snap.EntryToExitLatency = lat
snap.EntryToExitLoss = loss
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
probeOK = false
}
break
} else {
snap.ErrorMessage = resolveErr.Error()
probeOK = false
}
}
if probeOK {
snap.EntryToExitLatency = totalLat
snap.EntryToExitLoss = (1.0 - remainingSuccessProb) * 100.0
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
}
if len(hops) > 0 {
if b, err := json.Marshal(hops); err == nil {
snap.ChainDetails = string(b)
}
snap.ErrorMessage = "出口节点不可用"
probeOK = false
}
}
@@ -444,7 +357,6 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
Success: successInt,
ErrorMessage: snap.ErrorMessage,
Timestamp: snap.Timestamp,
ChainDetails: snap.ChainDetails,
}
if err := h.repo.InsertTunnelQuality(q); err != nil {
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
+17 -40
View File
@@ -15,6 +15,7 @@ import (
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
@@ -22,9 +23,6 @@ const (
releaseChannelStable = "stable"
releaseChannelDev = "dev"
defaultGithubProxyEnabled = true
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
)
var (
@@ -72,39 +70,6 @@ func releaseChannelLabel(channel string) string {
return "正式版"
}
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = defaultGithubProxyEnabled
proxyURL = defaultGithubProxyURL
if h == nil || h.repo == nil {
return
}
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
@@ -184,8 +149,14 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
}
}
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
"downloadUrl": downloadURL,
@@ -242,8 +213,14 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
}
}
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
type upgradeResult struct {
ID int64 `json:"id"`
-184
View File
@@ -1,184 +0,0 @@
package license
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type KeygenClient struct {
AccountID string
Token string
HTTPClient *http.Client
}
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
type ActivateMachineRequest struct {
Data struct {
Type string `json:"type"`
Attributes struct {
Fingerprint string `json:"fingerprint"`
} `json:"attributes"`
Relationships struct {
License struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"license"`
} `json:"relationships"`
} `json:"data"`
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
meta := map[string]interface{}{
"key": key,
}
if fingerprint != "" {
meta["scope"] = map[string]interface{}{
"fingerprint": fingerprint,
}
}
reqBody := map[string]interface{}{
"meta": meta,
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
"key": key,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
reqBody.Data.Attributes.Fingerprint = fingerprint
reqBody.Data.Relationships.License.Data.Type = "licenses"
reqBody.Data.Relationships.License.Data.ID = licenseID
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
// Machine already registered to this license or limit reached because it's already us.
// The subsequent ValidateKey check will determine if the existing machine is actually us.
return nil
}
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
@@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) {
time.Sleep(100 * time.Millisecond)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) {
<-ctx.Done()
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) {
svc.flushNodeMetrics()
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
metrics, err := svc.GetMetrics(1, 0, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) {
svc.pruneMetrics()
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) {
svc.flushNodeMetrics()
for nodeID := int64(1); nodeID <= 3; nodeID++ {
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics for node %d: %v", nodeID, err)
}
@@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
-1
View File
@@ -728,7 +728,6 @@ type TunnelQuality struct {
Success int `gorm:"not null;default:1" json:"success"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
ChainDetails string `gorm:"column:chain_details;type:text" json:"chainDetails,omitempty"`
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
+9 -68
View File
@@ -5,11 +5,9 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -683,7 +681,6 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"expiryReminderDismissed": n.ExpiryReminderDismissed,
"interfaceName": nullableString(n.InterfaceName),
})
}
return items, nil
@@ -3244,7 +3241,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
}
if ip != "" {
pair := formatForwardIngressAddress(ip, row.Port.Int64)
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
if _, ok := seenPairs[pair]; !ok {
seenPairs[pair] = struct{}{}
entries = append(entries, pair)
@@ -3261,17 +3258,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
return strings.Join(entries, ","), inPort, nil
}
func formatForwardIngressAddress(host string, port int64) string {
host = strings.TrimSpace(host)
if host == "" || port <= 0 {
return ""
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
}
func nullableString(v sql.NullString) interface{} {
if v.Valid {
return v.String
@@ -3334,60 +3320,15 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
if r == nil || r.db == nil {
return nil, nil
}
rangeMs := endMs - startMs
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
const targetPoints = 500 // target number of chart points for downsampled data
// For short ranges, return raw data (full resolution).
if rangeMs <= maxRawRangeMs {
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp ASC").
Limit(5000).
Find(&metrics).Error
return metrics, err
}
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
bucketMs := rangeMs / targetPoints
if bucketMs < 1000 {
bucketMs = 1000 // minimum 1-second buckets
}
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
var metrics []model.NodeMetric
err := r.db.Model(&model.NodeMetric{}).
Select(
fmt.Sprintf(
"%d AS node_id, "+
"CAST(%s AS BIGINT) AS timestamp, "+
"AVG(cpu_usage) AS cpu_usage, "+
"AVG(mem_usage) AS mem_usage, "+
"AVG(disk_usage) AS disk_usage, "+
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
"AVG(load1) AS load1, "+
"AVG(load5) AS load5, "+
"AVG(load15) AS load15, "+
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
"CAST(MAX(uptime) AS BIGINT) AS uptime",
nodeID, bucketExpr,
),
).
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Group(groupExpr).
Order("timestamp ASC").
Limit(targetPoints + 100). // safety margin
Scan(&metrics).Error
if metrics == nil {
metrics = make([]model.NodeMetric, 0)
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&metrics).Error
if len(metrics) > 1 {
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
metrics[i], metrics[j] = metrics[j], metrics[i]
}
}
return metrics, err
}
@@ -80,37 +80,6 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
return rows, nil
}
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -262,24 +231,6 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
return count > 0, nil
}
func (r *Repository) CountActiveForwardsByUser(userID int64) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND status = 1", userID).Count(&count).Error
return count, err
}
func (r *Repository) CountActiveForwardsByUserTunnel(userID, tunnelID int64) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Count(&count).Error
return count, err
}
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
@@ -11,7 +11,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
return nil, errors.New("repository not initialized")
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Where("is_remote = ?", 0).
Order("inx ASC, id ASC").
Find(&nodes).Error
@@ -1,11 +1,9 @@
package repo
import (
"crypto/rand"
"database/sql"
"errors"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
@@ -455,14 +453,6 @@ func (r *Repository) IsRemoteNodeTx(tx *gorm.DB, nodeID int64) (bool, error) {
}
func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, false)
}
func (r *Repository) PickRandomNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, true)
}
func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64, randomPick bool) (int, error) {
if tx == nil {
return 0, errors.New("database unavailable")
}
@@ -515,7 +505,6 @@ func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
}
}
var available []int
for _, candidate := range candidates {
if candidate <= 0 {
continue
@@ -523,25 +512,11 @@ func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
if _, ok := used[candidate]; ok {
continue
}
available = append(available, candidate)
allocated[nodeID] = candidate
return candidate, nil
}
if len(available) == 0 {
return 0, errors.New("节点端口已满,无可用端口")
}
if !randomPick {
allocated[nodeID] = available[0]
return available[0], nil
}
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
if err != nil {
allocated[nodeID] = available[0]
return available[0], nil
}
port := available[idx.Int64()]
allocated[nodeID] = port
return port, nil
return 0, errors.New("节点端口已满,无可用端口")
}
func (r *Repository) GetTunnelIPPreference(tunnelID int64) string {
@@ -1022,16 +997,9 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
}
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
// so the handler layer can clean up associated forwarding rules.
type RevokedUserTunnelPair struct {
UserID int64
TunnelID int64
}
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
if tx == nil {
return nil, errors.New("database unavailable")
return errors.New("database unavailable")
}
currentSet := make(map[int64]struct{}, len(currentUserIDs))
for _, uid := range currentUserIDs {
@@ -1050,7 +1018,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
}
}
if len(removedUserIDs) == 0 {
return nil, nil
return nil
}
type grantRow struct {
@@ -1058,8 +1026,6 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
CreatedByGroup int
}
var revoked []RevokedUserTunnelPair
for _, userID := range removedUserIDs {
var rows []grantRow
if err := tx.Model(&model.GroupPermissionGrant{}).
@@ -1067,7 +1033,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
Find(&rows).Error; err != nil {
return revoked, err
return err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1080,32 +1046,28 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return revoked, err
return err
}
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return revoked, err
return err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return revoked, err
return err
}
}
}
}
return revoked, nil
return nil
}
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
if tx == nil {
return nil, errors.New("database unavailable")
return errors.New("database unavailable")
}
type grantRow struct {
@@ -1118,7 +1080,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
Select("user_tunnel_id, created_by_group").
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Find(&rows).Error; err != nil {
return nil, err
return err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1130,27 +1092,22 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return nil, err
return err
}
var revoked []RevokedUserTunnelPair
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return revoked, err
return err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return revoked, err
return err
}
}
}
return revoked, nil
return nil
}
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
@@ -1,376 +0,0 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardCreateBlockedWhenUserNumLimitExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(100)
tunnelID := int64(1)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'num_limit_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'num_limit_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, ?, 'num_limit_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, ?, 'num_limit_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 2: %v", err)
}
token, err := auth.GenerateToken(userID, "num_limit_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"new_forward","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
}
if !strings.Contains(out.Msg, "转发数量已达上限") {
t.Fatalf("expected forward count limit message, got %q", out.Msg)
}
}
func TestForwardResumeBlockedWhenUserNumLimitExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(101)
tunnelID := int64(1)
pausedForwardID := int64(3)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'num_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'num_resume_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, ?, 'num_resume_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, ?, 'num_resume_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 2: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, 'num_resume_user', 'paused_forward', ?, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, pausedForwardID, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert paused forward: %v", err)
}
token, err := auth.GenerateToken(userID, "num_resume_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":3}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
}
if !strings.Contains(out.Msg, "转发数量已达上限") {
t.Fatalf("expected forward count limit message, got %q", out.Msg)
}
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, pausedForwardID)
if status != 0 {
t.Fatalf("expected forward status to remain 0, got %d", status)
}
}
func TestForwardCreateBlockedWhenUserTunnelNumLimitExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(102)
tunnelID := int64(1)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'ut_num_limit_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'ut_num_limit_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 1, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel with num=1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, ?, 'ut_num_limit_user', 'existing_tunnel_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward: %v", err)
}
token, err := auth.GenerateToken(userID, "ut_num_limit_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"new_tunnel_forward","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected non-zero code when user_tunnel num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
}
if !strings.Contains(out.Msg, "隧道转发数量已达上限") {
t.Fatalf("expected tunnel forward count limit message, got %q", out.Msg)
}
}
func TestForwardCreateAllowedWhenBelowUserNumLimit(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(103)
tunnelID := int64(1)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'num_ok_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 3, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'num_ok_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('num-ok-entry', 'num-ok-secret', '10.50.0.1', '10.50.0.1', '', '10000-10010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "num-ok-entry")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 10001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, ?, 'num_ok_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 1: %v", err)
}
token, err := auth.GenerateToken(userID, "num_ok_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"new_forward_ok","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected success (code=0) when below num limit, got code=%d msg=%q", out.Code, out.Msg)
}
}
func TestForwardCreateAllowedWhenNumZero(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(104)
tunnelID := int64(1)
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(?, 'num_zero_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 0, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'num_zero_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('num-zero-entry', 'num-zero-secret', '10.60.0.1', '10.60.0.1', '', '11000-11010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "num-zero-entry")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 11001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, ?, ?, NULL, 0, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel with num=0: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(1, ?, 'num_zero_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, ?, 'num_zero_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert existing forward 2: %v", err)
}
token, err := auth.GenerateToken(userID, "num_zero_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"new_forward_zero","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected success (code=0) when num=0 (unlimited), got code=%d msg=%q", out.Code, out.Msg)
}
}
@@ -1,80 +0,0 @@
package contract_test
import (
"testing"
"time"
)
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "issue349-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node a: %v", err)
}
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
t.Fatalf("insert node b: %v", err)
}
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
if err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "issue349-forward")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
t.Fatalf("insert forward_port b: %v", err)
}
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if out.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
}
rows := mustContractSlice(t, out.Data, "forward list data")
var target map[string]interface{}
for _, row := range rows {
item, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(item["id"]) == forwardID {
target = item
break
}
}
if target == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
}
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
t.Fatalf("expected first entry port 32001, got %d", got)
}
}
@@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) {
t.Fatalf("batch insert: %v", err)
}
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
retrieved, err := repo.GetNodeMetrics(1, 0, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -50,18 +50,21 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected create success (runtime deferred when nodes offline), got code=%d msg=%q", out.Code, out.Msg)
if out.Code == 0 {
t.Fatalf("expected create failure when nodes are offline")
}
if !strings.Contains(out.Msg, "节点") {
t.Fatalf("expected node-related error, got %q", out.Msg)
}
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
if tunnelCount != 1 {
t.Fatalf("expected tunnel record preserved (runtime deferred), found %d records", tunnelCount)
if tunnelCount != 0 {
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
}
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
if chainCount != 3 {
t.Fatalf("expected 3 chain_tunnel records preserved (in/chain/out), found %d records", chainCount)
if chainCount != 0 {
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
}
}
+1 -3
View File
@@ -119,9 +119,7 @@ func main() {
log := xlogger.NewLogger()
logger.SetDefault(log)
distro := socket.DetectDistro()
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+1 -11
View File
@@ -624,11 +624,6 @@ func resumeService(ctx *gin.Context) {
existingSvc.Close()
registry.ServiceRegistry().Unregister(name)
// 强制断开端口的所有连接
if serviceConfig.Addr != "" {
_ = kill.ForceClosePortConnections(serviceConfig.Addr)
}
// 等待端口释放
time.Sleep(500 * time.Millisecond)
@@ -1044,13 +1039,8 @@ func resumeServices(ctx *gin.Context) {
str.service.Close()
registry.ServiceRegistry().Unregister(str.name)
// 强制断开端口的所有连接
if str.serviceConfig.Addr != "" {
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
}
// 等待端口释放
time.Sleep(500 * time.Millisecond)
time.Sleep(100 * time.Millisecond)
// 重新解析并启动服务
svc, err := parser.ParseService(str.serviceConfig)
-21
View File
@@ -1,21 +0,0 @@
package socket
import (
"strings"
"github.com/shirou/gopsutil/v3/host"
)
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
// "debian"). Falls back to "linux" when detection fails.
func DetectDistro() string {
info, err := host.Info()
if err != nil || info == nil {
return "linux"
}
platform := strings.ToLower(strings.TrimSpace(info.Platform))
if platform == "" {
return "linux"
}
return platform
}
+1 -6
View File
@@ -397,13 +397,8 @@ func resumeServices(req resumeServicesRequest) error {
str.service.Close()
registry.ServiceRegistry().Unregister(str.name)
// 强制断开端口的所有连接
if str.serviceConfig.Addr != "" {
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
}
// 等待端口释放
time.Sleep(500 * time.Millisecond)
time.Sleep(100 * time.Millisecond)
// 重新解析并启动服务
svc, err := parser.ParseService(str.serviceConfig)
+1 -1
View File
@@ -348,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
+5 -69
View File
@@ -25,62 +25,10 @@ get_architecture() {
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy%/}"
fi
echo "${proxy}/${url}"
}
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
if [[ -n "$PROXY_URL" ]]; then
PROXY_ENABLED="true"
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
proxy_choice=""
fi
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
input_url=""
fi
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
echo "https://gcode.hostcentral.cc/${url}"
}
resolve_latest_release_tag() {
@@ -133,14 +81,9 @@ build_download_url() {
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
}
ensure_download_url_initialized() {
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
return 0
fi
RESOLVED_VERSION=$(resolve_version) || return 1
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
}
# 解析版本并构建下载地址
RESOLVED_VERSION=$(resolve_version) || exit 1
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
@@ -267,10 +210,6 @@ done
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
ask_proxy_config
ensure_download_url_initialized || exit 1
get_config_params
# 检查并安装 tcpkill
@@ -369,9 +308,6 @@ update_flux_agent() {
echo "❌ flux_agent 未安装,请先选择安装。"
return 1
fi
ask_proxy_config
ensure_download_url_initialized || return 1
echo "📥 使用下载地址: $DOWNLOAD_URL"
+5 -71
View File
@@ -13,62 +13,10 @@ REPO="Sagit-chu/flux-panel"
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
PINNED_VERSION=""
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy%/}"
fi
echo "${proxy}/${url}"
}
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
if [[ -n "$PROXY_URL" ]]; then
PROXY_ENABLED="true"
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
proxy_choice=""
fi
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
input_url=""
fi
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
echo "https://gcode.hostcentral.cc/${url}"
}
resolve_latest_release_tag() {
@@ -122,14 +70,9 @@ set_compose_urls_by_version() {
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
}
ensure_compose_urls_initialized() {
if [[ -n "${DOCKER_COMPOSEV4_URL:-}" && -n "${DOCKER_COMPOSEV6_URL:-}" ]]; then
return 0
fi
RESOLVED_VERSION=$(resolve_version) || return 1
set_compose_urls_by_version "$RESOLVED_VERSION"
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
set_compose_urls_by_version "$RESOLVED_VERSION"
@@ -431,10 +374,6 @@ get_config_params() {
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
check_docker
get_config_params
@@ -486,7 +425,6 @@ EOF
# 更新功能
update_panel() {
echo "🔄 开始更新面板..."
ask_proxy_config
check_docker
if [[ ! -f ".env" ]]; then
@@ -568,8 +506,6 @@ migrate_to_postgres() {
if [[ ! -f "docker-compose.yml" ]]; then
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
@@ -645,8 +581,6 @@ uninstall_panel() {
if [[ ! -f "docker-compose.yml" ]]; then
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载以完成卸载..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
-21
View File
@@ -1,21 +0,0 @@
# 013 - 隧道入口端口校验不严格修复
## Issue
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
## 修复方案
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
- **旧入口节点**:保留原端口不变
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
## 任务清单
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
- [x] 5. 构建通过 + 全量测试通过
-15
View File
@@ -1,15 +0,0 @@
# Plan 061: Node Logo By OS Type (Linux Distro)
## Objective
Display different logos for nodes in the 'Monitor - Node - Card/List View' based on their Linux distribution (Ubuntu, Debian, CentOS, Alpine, etc.).
## Tasks
- [x] Agent: Use `gopsutil/v3/host.Info().Platform` to detect the Linux distro and include it in the version string (`distro.go`).
- [x] Agent: Update `main.go` to call `socket.DetectDistro()` instead of `runtime.GOOS`.
- [x] Agent: Ensure version parameter is URL-escaped since it now includes distro info with special chars.
- [x] Backend: Select `version` column in `ListMonitorNodes`.
- [x] Backend: Include `version` in `monitorNodeListItem` JSON response.
- [x] Frontend: Add `version` to TypeScript interfaces (`MonitorNodeApiItem`, `MonitorNode`, `MonitorViewProps`).
- [x] Frontend: Create `distro-icon.tsx` component with SVG logos for Ubuntu, Debian, CentOS/Rocky/Alma, Alpine, Fedora, Arch/Manjaro, and a default Linux (Tux) fallback.
- [x] Frontend: Use `DistroIcon` in `ServerCard` (card view) and list view name column with branded colors per distro.
- [x] All three projects compile cleanly (`go build`, `tsc --noEmit`).
-49
View File
@@ -1,49 +0,0 @@
# Plan 062: Commit, PR, Merge and Tag
## Overview
This plan outlines the steps to commit all changes, create a PR, merge it, and then publish a new tag for the FLVX project.
## Checklist
- [ ] Check current git status for any unexpected changes
- [ ] Create a feature branch `feat-node-os-logo-release`
- [ ] Stage and commit all modifications and untracked files
- [ ] Push the feature branch to origin
- [ ] Create a Pull Request (PR) from the feature branch to `main`
- [ ] Merge the PR to `main`
- [ ] Update `AGENTS.md` with the new tag and commit hash
- [ ] Create and push new tag `2.1.9-beta14`
## Detailed Steps
### 1. Create Feature Branch
```bash
git checkout -b feat-node-os-logo-release
```
### 2. Commit all changes
Add all modified and untracked files:
```bash
git add .
git commit -m "feat: node OS logo support, UI rate overlap fix and tunnel monitoring updates"
```
### 3. Push and PR
Push to `origin`:
```bash
git push origin feat-node-os-logo-release
```
Create PR via `gh pr create` if possible.
### 4. Merge to Main
```bash
git checkout main
git merge feat-node-os-logo-release
git push origin main
```
### 5. Create Tag
Increment the current tag `2.1.9-beta13` to `2.1.9-beta14`.
```bash
git tag 2.1.9-beta14
git push origin 2.1.9-beta14
```
-12
View File
@@ -1,12 +0,0 @@
# Plan 063: Release 2.1.9-rc6
Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc6`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc6-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc6`.
-12
View File
@@ -1,12 +0,0 @@
# Plan 064: Release 2.1.9-rc7
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc7-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc7`.
@@ -1,11 +0,0 @@
# Fix Node Metrics PostgreSQL Type Encoding
## Objective
Fix the PostgreSQL type encoding error (`failed to encode args[0]: unable to encode 5 into text format for text (OID 25)`) and `integer out of range` error when querying node metrics for time ranges greater than 1 hour.
## Tasks
- [x] Identify the problematic downsampled SQL aggregation in `GetNodeMetrics`.
- [x] Fix the `? AS node_id` placeholder which confused PostgreSQL's type inference by directly embedding the `nodeID` using `fmt.Sprintf("%d AS node_id")`.
- [x] Change all `CAST(X AS INTEGER)` to `CAST(X AS BIGINT)` to prevent 32-bit integer overflow on Unix millisecond timestamps in PostgreSQL.
- [x] Verify the build and tests pass.
- [ ] Commit all changes, create a new branch, push, create a Pull Request, merge the PR into `main`, and publish a new tag `2.1.9-rc9`.
-11
View File
@@ -1,11 +0,0 @@
# 066 - Issue 349 IPv6 Entry Format
## Goal
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
## Checklist
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
- [x] 运行相关测试并确认通过。
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
@@ -1,22 +0,0 @@
# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes
**Issue:** https://github.com/Sagit-chu/flvx/issues/342
## Problem
When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database.
## Changes Required
### Backend
- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes:
- **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node.
- **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old".
- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially.
- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline.
### Frontend
- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit.
-25
View File
@@ -1,25 +0,0 @@
# PLAN: Detailed Hop-by-Hop Tunnel Quality Probing (Option B)
## Objective
Enhance the tunnel quality monitoring to correctly execute and record hop-by-hop latency and loss through the entire forwarding chain (Entry -> Mids -> Exit), rather than directly forcing Entry to ping Exit. Expose these details in the UI for advanced troubleshooting.
## Tasks
- [ ] **1. DB Schema & Model Updates**
- Update `model.TunnelQuality` in `model.go` with `ChainDetails string` (`gorm:"column:chain_details;type:text"`).
- GORM AutoMigrate will handle adding the column to SQLite/PostgreSQL automatically on backend restart.
- [ ] **2. Backend Data Structures (`tunnel_quality_prober.go`)**
- Define `TunnelQualityHop` to store `FromNodeID`, `FromNodeName`, `ToNodeID`, `ToNodeName`, `Latency`, `Loss`.
- Update `tunnelQualitySnapshot` to include `ChainDetails []TunnelQualityHop` (`json:"chainDetails,omitempty"`).
- Update DB query models to pass `ChainDetails` back to the frontend.
- [ ] **3. Prober Logic Restructuring**
- In `tunnel_quality_prober.go:probeTunnel()`, handle `Type 2` (Forwarding Chain) properly.
- Extract the intermediate nodes using `splitChainNodeGroups`.
- Form the hop pairs: `in[0]->mid[0]`, `mid[i]->mid[i+1]`, `mid[last]->out[0]`.
- Probe each hop sequentially. Resolve target IPs via `resolveChainProbeTarget` using `connect_ip` fields and node preferences.
- Cumulative metrics: `EntryToExitLatency` = `sum(latency)`. `EntryToExitLoss` = $1 - \prod (1 - loss\_i)$.
- [ ] **4. Frontend API & Component**
- Add `chainDetails?: string;` to `TunnelQualityApiItem` in `vite-frontend/src/api/types.ts`.
- In `TunnelMonitorView`, parse the JSON string back into an array of hops if it exists.
- Design a horizontal topology diagram (e.g., using `heroui/chip` and `lucide-react` arrows) to show `[上海入口] --25ms--> [香港跳板] --15ms--> [落地出口]`.
- Highlight bottlenecks (e.g., > 100ms or loss > 0%) in yellow or red.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@flvx/skill-api",
"version": "2.1.9-rc4",
"version": "2.1.5",
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
-314
View File
@@ -1,314 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fail() {
echo "FAIL: $1" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local message="$3"
if [[ "$actual" != "$expected" ]]; then
fail "$message (expected: $expected, actual: $actual)"
fi
}
load_script_without_main() {
local script_path="$1"
local temp_file
temp_file=$(mktemp)
sed '/^# 执行主函数$/,$d' "$script_path" > "$temp_file"
VERSION="v-test"
source "$temp_file"
rm -f "$temp_file"
}
test_install_script_respects_disabled_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED="false"
PROXY_URL=""
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://github.com/example/release" \
"$actual" \
"install.sh should bypass the proxy when disabled"
)
test_install_script_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < <(printf '\nmirror.example.com/\n')
assert_equals "true" "$PROXY_ENABLED" "install.sh should enable proxy by default"
assert_equals "mirror.example.com/" "$PROXY_URL" "install.sh should keep the entered proxy URL"
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://mirror.example.com/https://github.com/example/release" \
"$actual" \
"install.sh should normalize the entered proxy URL"
)
test_install_script_recomputes_download_url_after_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL=""
DOWNLOAD_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
ensure_download_url_initialized
local expected
expected=$(build_download_url)
assert_equals \
"$expected" \
"$DOWNLOAD_URL" \
"install.sh should build the final download URL after the interactive proxy choice"
)
test_update_flux_agent_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -d)
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
#!/bin/bash
echo "old version"
EOF
chmod +x "$INSTALL_DIR/flux_agent"
local ask_called="0"
ask_proxy_config() {
ask_called="1"
PROXY_ENABLED="false"
DOWNLOAD_URL=""
}
check_and_install_tcpkill() { :; }
systemctl() {
return 0
}
curl() {
local output=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == "-o" ]]; then
output="$2"
shift 2
continue
fi
shift
done
cat > "$output" <<'EOF'
#!/bin/bash
echo "new version"
EOF
chmod +x "$output"
}
update_flux_agent >/dev/null
assert_equals "1" "$ask_called" "update_flux_agent should ask for proxy config before downloading"
assert_equals "$(build_download_url)" "$DOWNLOAD_URL" "update_flux_agent should honor the prompted proxy choice"
)
test_update_flux_agent_skips_proxy_prompt_when_not_installed() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -u)
local ask_called="0"
ask_proxy_config() {
ask_called="1"
}
local rc="0"
update_flux_agent >/dev/null || rc="$?"
assert_equals "1" "$rc" "update_flux_agent should fail when the agent is not installed"
assert_equals "0" "$ask_called" "update_flux_agent should not prompt for proxy config when the agent is missing"
)
test_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL="mirror.example.com"
ask_proxy_config >/dev/null < <(printf '\n\n')
assert_equals "true" "$PROXY_ENABLED" "install.sh should treat PROXY_URL as enabling the proxy"
assert_equals "mirror.example.com" "$PROXY_URL" "install.sh should preserve PROXY_URL when provided via env"
)
test_panel_install_script_can_disable_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
assert_equals "false" "$PROXY_ENABLED" "panel_install.sh should allow disabling proxy"
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://github.com/example/release" \
"$actual" \
"panel_install.sh should bypass the proxy after disabling it"
)
test_panel_install_script_recomputes_compose_urls_after_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL=""
DOCKER_COMPOSEV4_URL=""
DOCKER_COMPOSEV6_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
ensure_compose_urls_initialized
assert_equals \
"https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml" \
"$DOCKER_COMPOSEV4_URL" \
"panel_install.sh should build the compose URL after the interactive proxy choice"
)
test_update_panel_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
local ask_called="0"
ask_proxy_config() {
ask_called="1"
PROXY_ENABLED="false"
DOCKER_COMPOSEV4_URL=""
DOCKER_COMPOSEV6_URL=""
}
check_docker() {
DOCKER_CMD="true"
}
get_current_db_type() {
echo "sqlite"
}
resolve_latest_release_tag() {
echo "v-test"
}
upsert_env_var() { :; }
check_ipv6_support() { return 1; }
configure_docker_ipv6() { :; }
docker() { return 0; }
wait_for_backend_healthy() { return 0; }
sleep() { :; }
curl() { :; }
update_panel >/dev/null
assert_equals "1" "$ask_called" "update_panel should ask for proxy config before downloading"
assert_equals \
"https://github.com/${REPO}/releases/download/v-test/docker-compose-v4.yml" \
"$DOCKER_COMPOSEV4_URL" \
"update_panel should honor the prompted proxy choice"
)
test_panel_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL="mirror.example.com"
ask_proxy_config >/dev/null < <(printf '\n\n')
assert_equals "true" "$PROXY_ENABLED" "panel_install.sh should treat PROXY_URL as enabling the proxy"
assert_equals "mirror.example.com" "$PROXY_URL" "panel_install.sh should preserve PROXY_URL when provided via env"
)
test_panel_install_script_defaults_proxy_on_eof() {
local rc="0"
local output
local temp_script
temp_script=$(mktemp)
sed '/^# 执行主函数$/,$d' "$ROOT_DIR/panel_install.sh" > "$temp_script"
cat >> "$temp_script" <<'EOF'
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < /dev/null
printf '%s\n%s\n' "$PROXY_ENABLED" "$PROXY_URL"
EOF
output=$(bash "$temp_script") || rc="$?"
rm -f "$temp_script"
assert_equals "0" "$rc" "panel_install.sh should not fail when proxy prompt receives EOF"
assert_equals $'true\ngcode.hostcentral.cc' "$output" "panel_install.sh should fall back to the default proxy on EOF"
}
test_panel_install_script_uses_default_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED="true"
PROXY_URL=""
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://gcode.hostcentral.cc/https://github.com/example/release" \
"$actual" \
"panel_install.sh should keep the default proxy when enabled"
)
test_install_script_respects_disabled_proxy
test_install_script_asks_for_proxy_config
test_install_script_recomputes_download_url_after_prompt
test_update_flux_agent_asks_for_proxy_config
test_update_flux_agent_skips_proxy_prompt_when_not_installed
test_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_can_disable_proxy
test_panel_install_script_recomputes_compose_urls_after_prompt
test_update_panel_asks_for_proxy_config
test_panel_install_script_uses_default_proxy
test_panel_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_defaults_proxy_on_eof
echo "install script proxy tests passed"
+1 -5
View File
@@ -134,7 +134,7 @@ export const getTunnelList = () =>
export const getTunnelById = (id: number) =>
Network.post<TunnelApiItem>("/tunnel/get", { id });
export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data, { timeout: 120_000 });
Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const previewTunnelDelete = (id: number) =>
@@ -251,9 +251,6 @@ export const updateConfigs = (configMap: Record<string, string>) =>
export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value });
export const activateLicense = (licenseKey: string) =>
Network.post("/license/activate", { license_key: licenseKey });
export const exportBackupData = () => Network.post("/backup/export");
export const importBackupData = (data: BackupImportPayload) =>
Network.post("/backup/import", data);
@@ -430,7 +427,6 @@ export const getNodeMetrics = (
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
{ timeout: 60_000 },
);
};
-13
View File
@@ -466,7 +466,6 @@ export interface MonitorNodeApiItem {
inx: number;
name: string;
status: number;
version?: string;
updatedTime: number;
}
@@ -489,17 +488,6 @@ export interface MonitorAccessApiData {
reason?: string;
}
export interface TunnelQualityHopApiItem {
fromNodeId: number;
fromNodeName: string;
toNodeId: number;
toNodeName: string;
latency: number;
loss: number;
targetIp?: string;
targetPort?: number;
}
export interface TunnelQualityApiItem {
tunnelId: number;
entryToExitLatency: number;
@@ -509,5 +497,4 @@ export interface TunnelQualityApiItem {
success: boolean;
errorMessage?: string;
timestamp: number;
chainDetails?: string;
}
File diff suppressed because one or more lines are too long
+11 -13
View File
@@ -94,19 +94,17 @@ export function VersionFooter({
</span>
)}
</p>
{siteConfig.hide_footer_brand !== true && (
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
)}
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
</div>
);
}
-11
View File
@@ -20,8 +20,6 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: "",
app_favicon: "",
is_commercial: false,
hide_footer_brand: false,
};
}
@@ -29,8 +27,6 @@ const getInitialConfig = () => {
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
const cachedAppFavicon =
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
if (cachedAppName) {
return {
@@ -40,8 +36,6 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
}
@@ -52,8 +46,6 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
};
@@ -280,9 +272,6 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
+2 -136
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -21,7 +20,6 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import {
updateConfigs,
activateLicense,
exportBackup,
importBackup,
getAnnouncement,
@@ -120,24 +118,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
type: "input",
},
{
key: "hide_footer_brand",
label: "隐藏页面底部 FLVX 版权信息",
description: "需商业版授权才能生效",
type: "switch",
},
{
key: "forward_compact_mode",
label: "规则页面精简模式",
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
key: "monitor_tunnel_quality_enabled",
label: "实时隧道质量检测",
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
type: "switch",
},
{
key: "captcha_enabled",
label: "启用验证码",
@@ -162,21 +148,6 @@ const CONFIG_ITEMS: ConfigItem[] = [
dependsOn: "captcha_enabled",
dependsValue: "true",
},
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
];
const BACKUP_TYPE_OPTIONS = [
@@ -204,13 +175,10 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
"github_proxy_enabled",
"github_proxy_url",
];
const initialConfigs: Record<string, string> = {};
@@ -248,10 +216,6 @@ export default function ConfigPage() {
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
const [importFileName, setImportFileName] = useState("");
const backupFileInputRef = useRef<HTMLInputElement>(null);
const [activatingLicense, setActivatingLicense] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState("");
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
@@ -377,29 +341,6 @@ export default function ConfigPage() {
);
};
const handleActivateLicense = async () => {
if (!licenseKeyInput.trim()) {
toast.error("请输入有效的商业授权码");
return;
}
setActivatingLicense(true);
try {
const res = await activateLicense(licenseKeyInput.trim());
if (res.code === 0) {
toast.success("商业版授权激活成功!");
setLicenseKeyInput("");
await loadConfigs();
window.dispatchEvent(new CustomEvent("configUpdated"));
} else {
toast.error(res.msg || "授权激活失败");
}
} catch (e: any) {
toast.error(e.message || "授权激活出错");
} finally {
setActivatingLicense(false);
}
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -466,15 +407,6 @@ export default function ConfigPage() {
detail: { changedKeys },
}),
);
// 如果隧道质量检测开关变更,通知 tunnel-monitor-view
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
window.dispatchEvent(
new CustomEvent("monitorTunnelQualityEnabledChanged", {
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
}),
);
}
} else {
toast.error("保存配置失败: " + response.msg);
}
@@ -625,7 +557,6 @@ export default function ConfigPage() {
const value = (configs[key] || "").trim();
const uploading = brandUploading[key] === true;
const isLogo = key === "app_logo";
const isCommercialDisabled = configs.is_commercial !== "true";
return (
<div
@@ -639,7 +570,6 @@ export default function ConfigPage() {
ref={getBrandInputRef(key)}
accept={BRAND_FILE_ACCEPT}
className="hidden"
disabled={uploading || isCommercialDisabled}
type="file"
onChange={(event) => {
void handleBrandFileChange(key, event);
@@ -650,7 +580,6 @@ export default function ConfigPage() {
<Button
color="primary"
isLoading={uploading}
isDisabled={isCommercialDisabled}
size="sm"
variant="flat"
onPress={() => triggerBrandFilePicker(key)}
@@ -691,7 +620,6 @@ export default function ConfigPage() {
const renderConfigItem = (item: ConfigItem) => {
const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key];
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
switch (item.type) {
case "input":
@@ -712,8 +640,6 @@ export default function ConfigPage() {
value={configs[item.key] || ""}
variant="bordered"
onChange={(e) => handleConfigChange(item.key, e.target.value)}
isDisabled={isCommercialDisabled}
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
/>
);
@@ -723,12 +649,12 @@ export default function ConfigPage() {
classNames={{
wrapper: isChanged ? "border-warning-300" : "",
}}
color="primary"
isSelected={configs[item.key] === "true"}
size="sm"
size="md"
onValueChange={(checked) =>
handleConfigChange(item.key, checked ? "true" : "false")
}
isDisabled={isCommercialDisabled}
>
<span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === "true" ? "已启用" : "已禁用"}
@@ -955,42 +881,6 @@ export default function ConfigPage() {
</div>
</div>
<Card className="shadow-md mb-6">
<CardHeader className="pb-6">
<div className="flex items-center w-full">
<div>
<h2 className="text-xl font-semibold">商业版授权</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)
</p>
</div>
</div>
</CardHeader>
<Divider />
<CardBody className="pt-8">
<div className="flex items-end gap-3 max-w-lg">
<Input
label="授权激活码"
placeholder="请输入 FLVX- 开头的商业授权码"
value={licenseKeyInput}
variant="bordered"
onChange={(e) => setLicenseKeyInput(e.target.value)}
isDisabled={configs.is_commercial === "true"}
description={configs.is_commercial === "true" ? "已激活商业版授权" : "需商业授权才能修改站名、图标并隐藏页脚品牌"}
/>
<Button
color="primary"
className="mb-6"
isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
isLoading={activatingLicense}
onPress={handleActivateLicense}
>
{configs.is_commercial === "true" ? "已授权" : "激活授权"}
</Button>
</div>
</CardBody>
</Card>
<Card className="shadow-md">
<CardHeader className="pb-6">
<div className="flex items-center w-full">
@@ -1314,30 +1204,6 @@ export default function ConfigPage() {
)}
</ModalContent>
</Modal>
{/* Floating Save Button (FAB) */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+3 -5
View File
@@ -987,11 +987,9 @@ const SortableCompactTableRow = ({
<span className="font-medium text-default-700 text-sm">
{forward.tunnelName}
</span>
{forward.tunnelTrafficRatio !== undefined && normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 && (
<span className="text-success font-bold text-[12px] ml-1.5 border border-success/30 rounded px-1 bg-success/10">
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
)}
<span className="text-success font-bold text-[12px] mr-1.5">
‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
</div>
</TableCell>
<TableCell
-2
View File
@@ -15,7 +15,6 @@ type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
version?: string;
};
type MonitorTab = "nodes" | "tunnels";
@@ -74,7 +73,6 @@ export default function MonitorPage() {
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
+182 -214
View File
@@ -6,7 +6,7 @@ import type {
ServiceMonitorLimitsApiData,
} from "@/api/types";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
LineChart,
Line,
@@ -27,13 +27,9 @@ import {
Server,
Clock,
ArrowLeft,
ArrowUp,
ArrowDown,
Eye,
} from "lucide-react";
import toast from "react-hot-toast";
import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon";
import {
getNodeMetrics,
@@ -77,7 +73,7 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps {
nodeMap: Map<number, { id: number; name: string; connectionStatus: string; version?: string }>;
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
viewMode?: "list" | "grid";
}
@@ -169,8 +165,6 @@ const getColorByUsage = (usage?: number) => {
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
const isOnline = node.connectionStatus === "online";
const distro = parseDistroFromVersion(node.version);
const distroColor = getDistroColor(distro);
return (
<Card
@@ -187,7 +181,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<DistroIcon distro={distro} className="w-5 h-5" style={{ color: isOnline ? distroColor : undefined }} />
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
</div>
@@ -269,143 +263,6 @@ type MetricType =
const METRICS_MAX_ROWS = 5000;
/* ─── Memoized Node Metrics Chart sub-component ─────────────────── */
interface NodeMetricsChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
activeMetricType: MetricType;
onMetricTypeChange: (t: MetricType) => void;
loading: boolean;
error: string | null;
truncated: boolean;
maxRows: number;
data: Array<Record<string, unknown>>;
nodeId: number | null;
onRefresh: (id: number) => void;
}
const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
];
const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
rangeMs, onRangeChange, activeMetricType, onMetricTypeChange,
loading, error, truncated, maxRows, data, nodeId, onRefresh,
}: NodeMetricsChartCardProps) {
const chartConfig = (() => {
switch (activeMetricType) {
case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" };
case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" };
case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" };
case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" };
case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" };
case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" };
}
})();
const yAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`;
case "load": return n.toFixed(1);
case "connections": return String(Math.round(n));
}
};
const tooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`;
case "load": return n.toFixed(2);
case "connections": return String(Math.round(n));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(rangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onRangeChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={loading} size="sm" variant="flat" onPress={() => nodeId && onRefresh(nodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{METRIC_TYPE_BUTTONS.map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => onMetricTypeChange(item.key)}
>
{item.label}
</Button>
))}
</div>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={yAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={tooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{truncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
);
});
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
checkerScanIntervalSec: 1,
minIntervalSec: 1,
@@ -612,11 +469,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return;
}
setMetricsTruncated(false);
const msg = response.msg || "加载指标失败";
const isTimeout = msg.toLowerCase().includes("timeout");
const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg;
setMetricsError(friendlyMsg);
if (!silent) toast.error(friendlyMsg);
setMetricsError(response.msg || "加载指标失败");
if (!silent) toast.error(response.msg || "加载指标失败");
} catch {
setMetricsTruncated(false);
if (!silent) setMetricsError("加载指标失败");
@@ -769,7 +623,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const timer = window.setInterval(() => {
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 5_000);
}, 1_000);
return () => window.clearInterval(timer);
}, [loadLatestMonitorResults, loadServiceMonitors]);
@@ -794,27 +648,13 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
void loadResultsForModal();
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
// Auto-load results for the resolved default monitor when entering detail view
useEffect(() => {
if (!detailNodeId) return;
if (activeServiceMonitorId) return; // user already selected one
// Find the first monitor belonging to this node (or panel-level)
const firstMonitor = serviceMonitors.find(
(m) => m.nodeId === detailNodeId || m.nodeId === 0,
);
if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) {
void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailNodeId, serviceMonitors]);
// Reload results for the active service monitor chart when time range changes
useEffect(() => {
if (!activeServiceMonitorId) return;
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
const chartData = useMemo(() => metrics.map((m) => ({
const chartData = metrics.map((m) => ({
time: formatTimestamp(m.timestamp, metricsRangeMs),
cpu: m.cpuUsage,
memory: m.memoryUsage,
@@ -826,7 +666,52 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
load15: m.load15,
tcp: m.tcpConns,
udp: m.udpConns,
})), [metrics, metricsRangeMs]);
}));
const getChartConfig = () => {
switch (activeMetricType) {
case "cpu":
return {
lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }],
yAxisLabel: "使用率 (%)",
};
case "memory":
return {
lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }],
yAxisLabel: "使用率 (%)",
};
case "disk":
return {
lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }],
yAxisLabel: "使用率 (%)",
};
case "network":
return {
lines: [
{ dataKey: "netIn", color: "#10b981", name: "入站速度" },
{ dataKey: "netOut", color: "#ef4444", name: "出站速度" },
],
yAxisLabel: "速度 (bytes/s)",
};
case "load":
return {
lines: [
{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" },
{ dataKey: "load5", color: "#8b5cf6", name: "负载 5m" },
{ dataKey: "load15", color: "#f59e0b", name: "负载 15m" },
],
yAxisLabel: "负载值",
};
case "connections":
return {
lines: [
{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" },
{ dataKey: "udp", color: "#10b981", name: "UDP 连接" },
],
yAxisLabel: "连接数",
};
}
};
@@ -1037,11 +922,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
);
// Backend batches DB writes every 30s, but latest API reads from in-memory cache.
// The cache timestamp reflects the real check time (every ~1s), so stale detection
// should still allow for the batch report interval + scan jitter.
const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval
const isResultStale = useCallback(
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
if (monitor.enabled !== 1) {
@@ -1052,9 +932,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
// Budget = batch report interval + one check interval + scan jitter + grace
const budgetMs =
SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
return Date.now() - latestResult.timestamp > budgetMs;
},
@@ -1094,6 +973,46 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return { disabled, ok, fail, unknown, stale };
}, [getLatestResult, isResultStale, serviceMonitors]);
const chartConfig = getChartConfig();
const nodeYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(0)}%`;
case "load":
return n.toFixed(1);
case "connections":
return String(Math.round(n));
}
};
const nodeTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(1)}%`;
case "load":
return n.toFixed(2);
case "connections":
return String(Math.round(n));
}
};
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
@@ -1185,40 +1104,25 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<DistroIcon distro={parseDistroFromVersion(node.version)} className="w-4 h-4 flex-shrink-0" style={{ color: isOnline ? getDistroColor(parseDistroFromVersion(node.version)) : undefined }} />
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
</div>
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-success-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-success-50 dark:bg-success-500/10 text-success-500">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-success-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
</div>
<div className="flex items-center gap-1.5 font-mono text-primary-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-500">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
<div className="flex items-center gap-1 font-mono text-primary-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
</div>
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
</div>
</div>
</TableCell>
@@ -1334,19 +1238,83 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
)}
{/* Node metrics chart */}
<NodeMetricsChartCard
rangeMs={metricsRangeMs}
onRangeChange={setMetricsRangeMs}
activeMetricType={activeMetricType}
onMetricTypeChange={setActiveMetricType}
loading={metricsLoading}
error={metricsError}
truncated={metricsTruncated}
maxRows={METRICS_MAX_ROWS}
data={chartData}
nodeId={selectedNodeId}
onRefresh={loadMetrics}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(metricsRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setMetricsRangeMs(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={metricsLoading} size="sm" variant="flat" onPress={() => selectedNodeId && loadMetrics(selectedNodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{([
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
] as { key: MetricType; label: string }[]).map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => setActiveMetricType(item.key)}
>
{item.label}
</Button>
))}
</div>
{metricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : metricsError ? (
<div className="text-center py-8 text-danger text-sm">{metricsError}</div>
) : metrics.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={nodeYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={nodeTooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{metricsTruncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
{/* Service monitors chart – same style as node metrics */}
@@ -2,10 +2,9 @@ import type {
MonitorTunnelApiItem,
TunnelMetricApiItem,
TunnelQualityApiItem,
TunnelQualityHopApiItem,
} from "@/api/types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LineChart,
Line,
@@ -24,7 +23,6 @@ import {
ArrowRightLeft,
Wifi,
WifiOff,
ArrowRight,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -33,7 +31,6 @@ import {
getTunnelMetrics,
getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -54,9 +51,6 @@ interface TunnelMonitorViewProps {
}
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
@@ -78,6 +72,17 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
});
};
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
/** Render a colored latency value with appropriate visual cue */
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
@@ -94,6 +99,7 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
}
/** Animated pulse dot for live status */
function LiveDot() {
return (
<span className="relative flex h-2 w-2">
@@ -176,216 +182,6 @@ function UptimeHistoryBar({
);
}
const TIME_RANGE_OPTIONS = [
{ key: String(15 * 60 * 1000), label: "15分钟" },
{ key: String(60 * 60 * 1000), label: "1小时" },
{ key: String(6 * 60 * 60 * 1000), label: "6小时" },
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
];
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
{TIME_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.key}>{opt.label}</SelectItem>
))}
</Select>
);
}
interface QualityChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: QualityChartCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
);
});
interface TrafficChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: TrafficChartCardProps) {
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={yFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
);
});
function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
if (!hopsStr) return null;
let hops: TunnelQualityHopApiItem[] = [];
try {
hops = JSON.parse(hopsStr);
} catch {
return null;
}
if (!Array.isArray(hops) || hops.length === 0) return null;
return (
<Card className="border border-divider/60 shadow-sm transition-shadow bg-gradient-to-br from-background to-default-50/50 mt-4">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between pb-1">
<h3 className="text-sm font-semibold flex items-center gap-1.5 text-default-700">
<Activity className="w-4 h-4 text-primary" />
全链路拓扑状态 (实时)
</h3>
</CardHeader>
<CardBody className="py-2 px-4 pb-4">
<div className="flex items-center overflow-x-auto pb-2 py-2">
{hops.map((hop, index) => {
const hasError = hop.latency < 0 || hop.loss > 0;
const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
const borderColor = hasError ? "border-danger" : "";
return (
<React.Fragment key={index}>
{index === 0 && (
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
{hop.fromNodeName}
</Chip>
)}
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
</span>
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
</div>
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
{hop.loss.toFixed(0)}% 丢包
</span>
</div>
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
{hop.toNodeName}
</Chip>
</React.Fragment>
);
})}
</div>
</CardBody>
</Card>
);
}
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
@@ -398,8 +194,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
// Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
@@ -447,25 +241,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}
}, []);
const loadMonitorTunnelQualityEnabled = useCallback(async () => {
try {
const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
);
setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string"
? response.data.value === "true"
: true,
);
} catch {
setMonitorTunnelQualityEnabled(true);
}
}, []);
useEffect(() => {
void loadTunnels();
void loadMonitorTunnelQualityEnabled();
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
}, [loadTunnels]);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -475,32 +253,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer);
}, [loadTunnels]);
useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled);
if (!enabled) {
setQualityLoading(false);
}
} else {
void loadMonitorTunnelQualityEnabled();
}
};
window.addEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
return () => {
window.removeEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
};
}, [loadMonitorTunnelQualityEnabled]);
// --- Initial history load ---
useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true;
@@ -573,22 +326,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}, []);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
return;
}
void loadQuality();
}, [loadQuality, monitorTunnelQualityEnabled]);
}, [loadQuality]);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
qualityTimerRef.current = window.setInterval(() => {
void loadQuality({ silent: true });
}, QUALITY_POLL_INTERVAL);
@@ -596,10 +337,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
};
}, [loadQuality, monitorTunnelQualityEnabled]);
}, [loadQuality]);
// --- Load quality history for detail chart ---
const loadQualityHistory = useCallback(
@@ -663,57 +403,50 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => {
if (detailTunnelId) {
void loadQualityHistory(detailTunnelId);
void loadTunnelMetrics(detailTunnelId);
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId);
}
}
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
// Auto-refresh detail charts
useEffect(() => {
if (!detailTunnelId) return;
const timer = window.setInterval(() => {
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId, { silent: true });
}
void loadQualityHistory(detailTunnelId, { silent: true });
void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
// Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo(
() => qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
[qualityHistory, qualityRangeMs],
);
// Chart data for quality history
const qualityChartData = qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
}));
const tunnelChartData = useMemo(
() => tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
[tunnelMetrics, tunnelRangeMs],
);
// Chart data for traffic metrics
const tunnelChartData = tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
}));
const tunnelYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
return formatBytes(n);
};
const tunnelTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
return formatBytes(n);
};
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
@@ -735,6 +468,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]);
/** Shared time range Select component */
const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
);
// =====================
// RENDER
// =====================
if (accessDenied) {
return (
<Card>
@@ -752,6 +506,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
);
}
// ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) {
const quality = qualityMap[detailTunnelId];
@@ -816,17 +571,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500">
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时隧道质量检测已关闭</span>
</>
)}
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
@@ -837,32 +583,123 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)}
</div>
{/* ====== Chain Topology ====== */}
{monitorTunnelQualityEnabled && quality?.chainDetails && (
<ForwardingChainTopology hopsStr={quality.chainDetails} />
)}
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
<Button
isLoading={qualityHistoryLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{qualityHistoryLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : qualityHistoryError ? (
<div className="text-center py-8 text-danger text-sm">{qualityHistoryError}</div>
) : qualityChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={qualityChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}
onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
tunnelId={detailTunnelId}
onRefresh={loadQualityHistory}
/>
<Line
connectNulls
dataKey="entryToExit"
dot={false}
name="entryToExit"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
connectNulls
dataKey="exitToBing"
dot={false}
name="exitToBing"
stroke="#3b82f6"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
{/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard
rangeMs={tunnelRangeMs}
onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
tunnelId={detailTunnelId}
onRefresh={loadTunnelMetrics}
/>
{/* ====== Traffic Chart (unchanged) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={tunnelRangeMs} onChange={setTunnelRangeMs} />
<Button
isLoading={tunnelMetricsLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadTunnelMetrics(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{tunnelMetricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : tunnelMetricsError ? (
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
) : tunnelChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={tunnelChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={tunnelTooltipFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
</div>
);
}
@@ -872,26 +709,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{lastQualityUpdate ? (
{lastQualityUpdate && (
<div className="flex items-center gap-1.5 text-xs text-default-500">
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
</>
)}
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</div>
) : !monitorTunnelQualityEnabled ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭</span>
</div>
) : null}
)}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -963,17 +786,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1">
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
<LiveDot />
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
</span>
<span className="text-[11px] text-default-400">等待探测...</span>
)}
</div>
</CardBody>
@@ -1027,17 +844,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableCell>
{quality?.timestamp ? (
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
<LiveDot />
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-xs text-default-400">
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
</span>
<span className="text-xs text-default-400">-</span>
)}
</TableCell>
</TableRow>
+30 -34
View File
@@ -32,9 +32,6 @@ interface PanelAddress {
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
typeof value === "string" ? value === "true" : defaultValue;
export const SettingsPage = () => {
const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
@@ -53,16 +50,9 @@ export const SettingsPage = () => {
setPanelAddresses(newAddress);
};
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表
const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses();
};
@@ -82,6 +72,7 @@ export const SettingsPage = () => {
return;
}
(window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim());
setNewName("");
setNewAddress("");
@@ -90,12 +81,14 @@ export const SettingsPage = () => {
// 设置当前面板地址
const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name);
reinitializeBaseURL();
toast.success("删除成功");
@@ -110,7 +103,12 @@ export const SettingsPage = () => {
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
const enabled =
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
} catch {
setForwardCompactMode(false);
}
@@ -218,30 +216,28 @@ export const SettingsPage = () => {
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</CardBody>
</Card>
+1 -1
View File
@@ -418,7 +418,7 @@ export default function TunnelPage() {
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes, isEdit);
const newErrors = validateTunnelForm(form, nodes);
setErrors(newErrors);
+7 -12
View File
@@ -33,7 +33,6 @@ export const createTunnelFormDefaults = () => {
export const validateTunnelForm = (
form: TunnelFormInput,
nodes: TunnelNodeInput[],
isEdit = false,
): Record<string, string> => {
const errors: Record<string, string> = {};
@@ -45,9 +44,7 @@ export const validateTunnelForm = (
if (!form.inNodeId || form.inNodeId.length === 0) {
errors.inNodeId = "请至少选择一个入口节点";
} else if (!isEdit) {
// Only enforce online check for new tunnels. During edit the backend
// allows existing offline nodes (user may be removing them).
} else {
const offlineInNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
@@ -67,16 +64,14 @@ export const validateTunnelForm = (
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
if (!isEdit) {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
return node && node.status !== 1;
});
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
const inNodeIds = form.inNodeId.map((item) => item.nodeId);