mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25dc03c894 | |||
| 4cf9c9174a | |||
| a45bd43e73 | |||
| 364bace87f | |||
| 2caafab937 | |||
| 901b903a70 | |||
| 930cc86614 | |||
| 101fd5805a | |||
| 938e9ff4fd | |||
| ebf412b9df | |||
| 9a85363e44 | |||
| 7b9b59644e | |||
| 4e088afb29 | |||
| 1b3ae44940 | |||
| a91abbfebd | |||
| 513591fe67 | |||
| 9412d24c02 | |||
| ab3ca019d2 | |||
| b892b2640e | |||
| 3da9b14bfe | |||
| 49ab2915ee | |||
| becf87118f | |||
| 608fbf74de | |||
| 8b9cdef0e4 | |||
| 3c10727e08 | |||
| 841d43344a | |||
| 5efe790937 | |||
| eec6cb4298 | |||
| 352fc82907 | |||
| 87722e461c | |||
| 701b4011cb | |||
| d128d2f657 | |||
| 400a40fe80 | |||
| 103290ed35 | |||
| 363e714603 | |||
| afd1258fcd | |||
| e69082a596 | |||
| d30363d164 | |||
| 8e1a87bf5a | |||
| 6180b5a198 | |||
| 61d95ab5d5 | |||
| c27be19915 | |||
| f62a35c3f9 | |||
| 2a1caf32c4 | |||
| fdcc30a493 | |||
| efaffb0475 | |||
| 4954526cbc | |||
| 9d50071915 | |||
| ceceee6ebd | |||
| 11051f5517 | |||
| ff2c7c4959 | |||
| 6364b96935 | |||
| 409f0a232a | |||
| f79994e0e0 | |||
| 9fdb16d035 | |||
| 53b632a6f7 | |||
| bf7b2a0740 | |||
| 8475bc27bb | |||
| 5d01572eff | |||
| a353faaa71 | |||
| e7b25004ba | |||
| 3826cb02c0 | |||
| a1fee8e432 | |||
| aafdb78482 | |||
| 16b545d8cd | |||
| 45065178b8 | |||
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 | |||
| b7065f6e99 | |||
| bd4e1f66cb | |||
| 9f0670f4d0 | |||
| 32e338d295 | |||
| f6a753baa3 | |||
| 322a10bb9d | |||
| 27c13d6c47 | |||
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e | |||
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 |
@@ -67,6 +67,7 @@ go-gost/ss/
|
||||
.entire/
|
||||
bin/
|
||||
tmp/
|
||||
.worktrees/
|
||||
*.swp
|
||||
*.bak
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Mar 19 2026
|
||||
**Commit:** 6458b5a
|
||||
**Generated:** Tue Mar 24 2026
|
||||
**Commit:** 8ebde9d
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-alpha5
|
||||
**Tag:** 2.1.9-rc10
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
||||
#### 安装特定版本
|
||||
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
|
||||
|
||||
面板端(以 2.1.0 为例):
|
||||
面板端(以 2.1.9-beta6 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端(以 2.1.0 为例):
|
||||
节点端(以 2.1.9-beta6 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### PostgreSQL 部署(Docker Compose)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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
|
||||
@@ -0,0 +1,520 @@
|
||||
# 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. 面板生成的安装命令包含加速配置环境变量
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,347 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
# Announcement Popup Notification 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 popup modal for announcements that automatically shows to users when a new or updated announcement is published.
|
||||
|
||||
**Architecture:** We will modify the Go backend to return `update_time` along with the announcement data. In the Vite frontend, we will store the user's `flvx_announcement_seen_time` in `localStorage`. If the fetched `update_time` is greater than the stored timestamp, we trigger a NextUI Modal displaying the announcement content.
|
||||
|
||||
**Tech Stack:** Go, Vite, React, TailwindCSS, NextUI.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update API Response in Go Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Modify the `getAnnouncement` function in `go-backend/internal/http/handler/handler.go`.
|
||||
Find the response map inside `getAnnouncement` and add the `update_time` key:
|
||||
|
||||
```go
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"update_time": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
updateTime := ann.CreatedTime
|
||||
if ann.UpdatedTime.Valid {
|
||||
updateTime = ann.UpdatedTime.Int64
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"update_time": updateTime,
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go
|
||||
git commit -m "feat(api): include update_time in announcement response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Frontend API Interface
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Modify the `AnnouncementData` interface in `vite-frontend/src/api/index.ts` to include `update_time`.
|
||||
|
||||
```typescript
|
||||
export interface AnnouncementData {
|
||||
content: string;
|
||||
enabled: number;
|
||||
update_time?: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/api/index.ts
|
||||
git commit -m "feat(ui): add update_time to AnnouncementData interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create AnnouncementModal Component
|
||||
|
||||
**Files:**
|
||||
- Create: `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Create `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx` with the following content:
|
||||
|
||||
```tsx
|
||||
import type { AnnouncementData } from "@/api";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
announcement: AnnouncementData;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export const AnnouncementModal = ({
|
||||
announcement,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDontShowAgain,
|
||||
}: AnnouncementModalProps) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={onDontShowAgain}>
|
||||
不再提示
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
|
||||
git commit -m "feat(ui): create AnnouncementModal component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integrate Modal State in Dashboard Custom Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
|
||||
|
||||
- [ ] **Step 1: Update the hook return type interface**
|
||||
|
||||
At the top of `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` where `DashboardData` is or similar, add the new properties (if it uses an explicit return type). If it's inferred, skip this. Wait, let's check the code:
|
||||
|
||||
```typescript
|
||||
isAnnouncementModalOpen: boolean;
|
||||
setIsAnnouncementModalOpen: (isOpen: boolean) => void;
|
||||
dismissAnnouncementModal: () => void;
|
||||
```
|
||||
Ensure they are added to the returned object at the bottom of the `useDashboardData` hook.
|
||||
|
||||
Find the `const loadAnnouncement` function.
|
||||
|
||||
- [ ] **Step 2: Write the minimal implementation**
|
||||
|
||||
First, add state at the top of the hook:
|
||||
```typescript
|
||||
const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
|
||||
```
|
||||
|
||||
Then, modify the `loadAnnouncement` logic inside `useDashboardData`:
|
||||
```typescript
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
|
||||
try {
|
||||
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
|
||||
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
|
||||
const updateTime = res.data.update_time || 0;
|
||||
|
||||
if (updateTime > storedTime) {
|
||||
setIsAnnouncementModalOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to read localStorage for announcement state", err);
|
||||
setIsAnnouncementModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
setAnnouncement(null);
|
||||
}
|
||||
```
|
||||
|
||||
Add the dismiss handler inside the hook:
|
||||
```typescript
|
||||
const dismissAnnouncementModal = useCallback(() => {
|
||||
setIsAnnouncementModalOpen(false);
|
||||
if (announcement && announcement.update_time) {
|
||||
try {
|
||||
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
|
||||
} catch (err) {
|
||||
console.warn("Failed to set localStorage for announcement state", err);
|
||||
}
|
||||
}
|
||||
}, [announcement]);
|
||||
```
|
||||
|
||||
Ensure these are included in the return object of the hook:
|
||||
```typescript
|
||||
isAnnouncementModalOpen,
|
||||
setIsAnnouncementModalOpen,
|
||||
dismissAnnouncementModal,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts
|
||||
git commit -m "feat(ui): manage announcement modal state in dashboard hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Modal to Dashboard Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the minimal implementation**
|
||||
|
||||
Import the modal component at the top:
|
||||
```tsx
|
||||
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
|
||||
```
|
||||
|
||||
Add the new properties to the destructured `useDashboardData` object:
|
||||
```tsx
|
||||
isAnnouncementModalOpen,
|
||||
setIsAnnouncementModalOpen,
|
||||
dismissAnnouncementModal,
|
||||
```
|
||||
|
||||
Add the modal instance near the end of the dashboard rendering (just below `{announcement && <AnnouncementBanner ... />}` or inside the main `<div>`):
|
||||
```tsx
|
||||
{announcement && (
|
||||
<AnnouncementModal
|
||||
announcement={announcement}
|
||||
isOpen={isAnnouncementModalOpen}
|
||||
onClose={() => setIsAnnouncementModalOpen(false)}
|
||||
onDontShowAgain={dismissAnnouncementModal}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/dashboard.tsx
|
||||
git commit -m "feat(ui): add announcement modal to dashboard layout"
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign 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:** Transform the Flvx frontend interface entirely into an "Apple iOS 26 Liquid Glass" visual style by utilizing high-radius squircles, heavy background blurs, mesh gradients, and highly semantic translucent containers.
|
||||
|
||||
**Architecture:** We will approach this from the ground up: first defining the global TailwindCSS design tokens and the base mesh-gradient layout, then systematically replacing the structural styling inside each React page component (`vite-frontend/src/pages/*.tsx`).
|
||||
|
||||
**Tech Stack:** React DOM, TailwindCSS (v4), shadcn-bridge (HeroUI), Vite
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Setup Global CSS Variables and App Shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/styles/globals.css` (or `index.css`)
|
||||
- Modify: `vite-frontend/tailwind.config.js`
|
||||
- Modify: `vite-frontend/src/App.tsx` (or `main.tsx` / `layouts` depending on structural entry point)
|
||||
|
||||
- [ ] **Step 1: Inject Liquid Glass theme variables**
|
||||
Open the main CSS file and add variables for the new blur radius, box-shadows, and background gradients.
|
||||
```css
|
||||
:root {
|
||||
--glass-bg: rgba(255, 255, 255, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.8);
|
||||
--glass-card: rgba(255, 255, 255, 0.7);
|
||||
--glass-overlay: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark {
|
||||
--glass-bg: rgba(30, 30, 30, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.15);
|
||||
--glass-card: rgba(40, 40, 40, 0.6);
|
||||
}
|
||||
.bg-mesh-gradient {
|
||||
background: radial-gradient(at 0% 0%, #ff9a9e 0%, transparent 50%),
|
||||
radial-gradient(at 100% 0%, #fecfef 0%, transparent 50%),
|
||||
radial-gradient(at 100% 100%, #c2e9fb 0%, transparent 50%),
|
||||
radial-gradient(at 0% 100%, #a1c4fd 0%, transparent 50%);
|
||||
background-color: #f2f2f7;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update App Layout**
|
||||
Modify the root app container to use `.bg-mesh-gradient` and ensure the main container occupies `min-h-screen`.
|
||||
|
||||
### Task 2: Refactor Global Components (Card & Modal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/card.tsx`
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/modal.tsx`
|
||||
|
||||
- [ ] **Step 1: Liquid Card Base**
|
||||
Update the default className string for `Card` to incorporate: `backdrop-blur-3xl bg-white/60 dark:bg-zinc-900/60 border border-white/80 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]`.
|
||||
|
||||
- [ ] **Step 2: Modal Overlay Base**
|
||||
Update the default overlay className for `Modal` to use `bg-black/30 backdrop-blur-sm`, and its content panel to use the same `glass_card` classes as the Card component but with `rounded-3xl`.
|
||||
|
||||
### Task 3: Redesign Dashboard Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace hardcoded borders/bg with glass semantics**
|
||||
Find hardcoded `bg-white`, `border-gray-200`, `shadow-md` inside `DashboardPage` and `MetricCard`, replace with `bg-white/60 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] border-white/80 rounded-2xl`.
|
||||
- [ ] **Step 2: Adjust spacing**
|
||||
Ensure all metric cards have uniform `h-48` equivalent height and are strictly `rounded-2xl` with `p-6` padding.
|
||||
- [ ] **Step 3: Update Flow Chart Card**
|
||||
Replace standard grid backgrounds in the flow chart with transparent spacing and vibrant `bg-blue-500` squircle bars without harsh borders.
|
||||
|
||||
### Task 4: Redesign Node Management Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/node.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace standard List/Table view with Grid Cards**
|
||||
Update the node rendering map to output `glass_card` containers (`rounded-2xl`, blur, padding `p-6`).
|
||||
- [ ] **Step 2: Apply semantic status highlights**
|
||||
Refactor the Online/Offline badges into pill-shapes (`rounded-full`) using the defined semantic colors (e.g. `bg-green-500/20 text-green-600` with a 6px inner dot `bg-green-500`).
|
||||
- [ ] **Step 3: Embed Micro-charts**
|
||||
For CPU/RAM data inside the node card, switch standard progress bars to ultra-thin (height 4px) continuous lines utilizing standard brand colors.
|
||||
|
||||
### Task 5: Redesign Tunnels & Rules Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tunnel lists into nested Glass Panels**
|
||||
Encapsulate each tunnel configuration into a wide `glass_card`.
|
||||
- [ ] **Step 2: Create Visual Rule Tags**
|
||||
For the Forwarding rules, wrap the target IP/Port logic into visual badges: `bg-green-500/20` for Entry and `bg-blue-500/20` for Target.
|
||||
- [ ] **Step 3: Refactor the "Add Rule" Floating action**
|
||||
Ensure the plus button follows the squircle format (`rounded-full`) with a prominent diffused shadow (`shadow-[0_4px_12px_rgba(0,122,255,0.3)]`).
|
||||
|
||||
### Task 6: Redesign Monitor Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/monitor.tsx`
|
||||
|
||||
- [ ] **Step 1: Style the Top Hero Metrics**
|
||||
Replace flat stat boxes with high-contrast, large typography inside `glass_card` backgrounds.
|
||||
- [ ] **Step 2: Refactor Latency Indicators**
|
||||
Format the connection list rows as `bg-white/50 dark:bg-black/30` strips with pill-shaped status tags (`Healthy`, `Warning`, `Offline`) mapping exactly to the green/orange/red semantics from the design spec.
|
||||
|
||||
### Task 7: Redesign Group & Sharing Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/group.tsx`
|
||||
- Modify: `vite-frontend/src/pages/panel-sharing.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tab Switchers**
|
||||
Refactor the internal navigation tabs (e.g., "Tunnel Groups" vs "User Groups") into an encapsulated `p-1 rounded-xl bg-white/40 backdrop-blur-lg` container with animated active states (`shadow-sm bg-white`).
|
||||
- [ ] **Step 2: Style Share Cards**
|
||||
Transform flat panel sharing list items into rich `glass_card` entities. Highlight expiration dates with the accent text color.
|
||||
|
||||
### Task 8: Redesign Settings, Config, and User Management
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
- Modify: `vite-frontend/src/pages/settings.tsx`
|
||||
- Modify: `vite-frontend/src/pages/user.tsx`
|
||||
- Modify: `vite-frontend/src/pages/limit.tsx`
|
||||
|
||||
- [ ] **Step 1: Flatten Forms**
|
||||
Convert traditional input groups into `rounded-xl bg-white/50 border border-white/60` containers. Remove outer boxing for standard `label + input` pairs.
|
||||
- [ ] **Step 2: iOS Toggle Switches**
|
||||
Ensure that any `<Switch>` or `<Checkbox>` components use the new Accent brand color (`#007aff`) with full `rounded-full` geometry.
|
||||
- [ ] **Step 3: Refactor User Badges**
|
||||
In `user.tsx`, replace text-based role columns with circular Avatar badges (e.g., `w-10 h-10 rounded-full bg-blue-500 text-white` with the first two letters of the username).
|
||||
|
||||
### Task 9: Profile & Password Modal Restyling
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/profile.tsx`
|
||||
- Modify: `vite-frontend/src/pages/change-password.tsx`
|
||||
|
||||
- [ ] **Step 1: Apply Profile Card Structure**
|
||||
Create a split view on desktop using flex: Left side (Avatar + User Info + Admin Shortcuts), Right side (Password Form).
|
||||
- [ ] **Step 2: Restyle Auth Inputs**
|
||||
Ensure all password inputs use `bg-white/50 backdrop-blur-md border border-white/60` and the update button has heavy shadow-glow.
|
||||
@@ -0,0 +1,162 @@
|
||||
# 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
|
||||
@@ -0,0 +1,274 @@
|
||||
# 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 修改
|
||||
- [ ] 测试:验证功能正常
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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`,接口应返回权限不足。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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. 测试激活、吊销、过期、断网等各种场景。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Announcement Popup Notification Design
|
||||
|
||||
## Overview
|
||||
This feature implements a popup notification modal for important dashboard announcements to ensure users see them immediately, addressing GitHub Issue #169.
|
||||
|
||||
## Requirements
|
||||
1. Automatic display of a popup modal when opening the dashboard page if a new/updated announcement exists.
|
||||
2. Includes a "Don't show again" option to remember the user's choice to dismiss it.
|
||||
3. Smart triggering: Only pops up for *new* or *updated* announcements.
|
||||
4. Support Markdown formatting for the announcement content.
|
||||
5. Retain the existing permanent top banner as a fallback.
|
||||
|
||||
## Backend Changes (Go)
|
||||
The `/api/v1/announcement/get` API currently only returns `content` and `enabled`. It must be updated to return the timestamp of the last update to enable the frontend to detect changes.
|
||||
|
||||
1. **Repository (`internal/store/repo/repository.go`)**: Ensure `GetAnnouncement` retrieves `UpdatedTime` (or falls back to `CreatedTime`).
|
||||
2. **Handler (`internal/http/handler/handler.go`)**: Modify `getAnnouncement` to include an `update_time` (int64) field in its JSON response.
|
||||
|
||||
## Frontend Changes (Vite/React/Tailwind)
|
||||
1. **API Interface (`src/api/index.ts`)**:
|
||||
* Update `AnnouncementData` to include `update_time: number`.
|
||||
2. **Storage Mechanism**:
|
||||
* Use browser `localStorage` to persist the user's view state. Key: `flvx_announcement_seen_time`.
|
||||
3. **UI Component (`AnnouncementModal`)**:
|
||||
* Create a new modal component for the dashboard.
|
||||
* The modal content will render the markdown of the announcement.
|
||||
* It will feature two primary actions:
|
||||
* **"Close"**: Closes the modal temporarily for this session (does NOT update `localStorage`). It will pop up again on the next page load.
|
||||
* **"Don't show again"**: Closes the modal AND sets `localStorage.setItem('flvx_announcement_seen_time', announcement.update_time)`.
|
||||
4. **Integration (`src/pages/dashboard.tsx` & `use-dashboard-data.ts`)**:
|
||||
* Add state to manage the modal visibility (e.g., `isAnnouncementModalOpen`).
|
||||
* On data load, compare the fetched `update_time` with the stored `flvx_announcement_seen_time`. If the fetched time is greater (or if no stored time exists), set `isAnnouncementModalOpen(true)`.
|
||||
|
||||
## Error Handling and Edge Cases
|
||||
* If `localStorage` is unavailable or throws an error (e.g., Private Browsing mode restrictions), the modal may show repeatedly. The code should safely catch `localStorage` access errors.
|
||||
* If `update_time` is missing from an old database record, the backend should gracefully fall back to the creation time or a safe default (like 0) to ensure the logic doesn't break.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign Spec
|
||||
|
||||
## 1. Overview
|
||||
This document specifies the comprehensive UI/UX redesign of the Flvx frontend using an "Apple iOS 26 Liquid Glass" design language. The goal is to elevate the visual quality of the entire application, making it modern, spatially aware, and highly legible through extensive use of blur, translucency, squircle borders, and semantic contrast.
|
||||
|
||||
## 2. Scope
|
||||
The redesign covers 100% of the frontend routing pages and overlay components under `vite-frontend/src/pages/` and global UI modules:
|
||||
- Dashboard (`dashboard.tsx`)
|
||||
- Node Management (`node.tsx`)
|
||||
- Tunnel & Rule Configurations (`tunnel.tsx`, `forward.tsx`)
|
||||
- System Monitor (`monitor.tsx`)
|
||||
- User Management (`user.tsx`)
|
||||
- Speed Limit Management (`limit.tsx`)
|
||||
- Group Management (`group.tsx`)
|
||||
- Panel Sharing (`panel-sharing.tsx`)
|
||||
- Global Settings & Config (`config.tsx`, `settings.tsx`)
|
||||
- Profile & Change Password (`profile.tsx`, `change-password.tsx`)
|
||||
- All related Modals, Drawers, and floating UI (e.g., "Create Node", "Add Rule" forms).
|
||||
|
||||
## 3. Design System & Tokens
|
||||
The new UI replaces traditional solid-color borders and flat surfaces with the following spatial design tokens:
|
||||
|
||||
### 3.1. Corner Radii (Squircles)
|
||||
- **Outer Shell / Viewports**: 32px (`rounded-3xl` equivalent)
|
||||
- **Cards / Containers**: 24px (`rounded-2xl`)
|
||||
- **Buttons / Inputs**: 16px (`rounded-xl` or `rounded-full`)
|
||||
- **Badges / Tags**: 6px or fully rounded.
|
||||
|
||||
### 3.2. Backgrounds & Blurs
|
||||
- **Global Background**: A mesh gradient blending soft pinks and blues (`#ff9a9e`, `#fecfef`, `#a1c4fd`, `#c2e9fb`).
|
||||
- **Glass Base (Primary Containers)**: `backdrop-blur-3xl` with an ultra-thin white overlay (`rgba(255, 255, 255, 0.6)` or `#ffffff99`).
|
||||
- **Glass Inner Glow (Borders)**: 1px solid `rgba(255, 255, 255, 0.8)` (`#ffffffcc`).
|
||||
- **Modals Background Overlay**: 30% black overlay (`#0000004d`).
|
||||
|
||||
### 3.3. Semantic Colors
|
||||
- **Brand / Active / Primary / TCP**: Blue `#007aff`
|
||||
- **Healthy / Success / Online**: Green `#34c759`
|
||||
- **Warning / Wait / UDP**: Orange `#ff9500`
|
||||
- **Danger / Offline / Delete**: Red `#ff3b30`
|
||||
- **Secondary / Purple / Data**: Purple `#af52de`
|
||||
- **Text**: Primary (`#1d1d1f`), Secondary (`#86868b`).
|
||||
|
||||
### 3.4. Elevation & Shadow
|
||||
- Soft, highly diffused drop shadows rather than sharp lines: e.g., `box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1)`.
|
||||
|
||||
## 4. Implementation Strategy
|
||||
We will implement the redesign systematically across the React + TailwindCSS + shadcn/HeroUI stack:
|
||||
1. **CSS Variables / Tailwind Config**: Inject the new Liquid Glass design tokens (colors, extended radiuses, customized backdrop blurs, box shadows) into `tailwind.config.js` and `globals.css`.
|
||||
2. **Global App Shell**: Update the root layout (`index.tsx` or main `App` layout) to host the dynamic mesh gradient background and the new translucent sidebar.
|
||||
3. **Component Re-styling**:
|
||||
- Override HeroUI default card, input, and modal styles using custom `classNames`.
|
||||
- Update `MetricCard`, `PageEmptyState`, `PageLoadingState`, and other base components to support the `glass_card` spec.
|
||||
4. **Page-by-Page Integration**: Rewrite the JSX of each page to utilize the new layout structure, ensuring all existing interactive state and API logic is seamlessly preserved.
|
||||
5. **Modal System Update**: Apply the transparent `#0000004d` overlay and 480px width glassy card style to all global dialogs.
|
||||
|
||||
## 5. Success Criteria
|
||||
- [ ] No regression in business logic; all forms, interactions, and data rendering operate exactly as before.
|
||||
- [ ] The visual system consistently employs the `glass_bg`, `glass_card`, and corresponding squircle radiuses across 100% of the UI.
|
||||
- [ ] Modals and Overlays correctly blur the background mesh gradient.
|
||||
- [ ] All responsive layouts appropriately wrap the card components on smaller displays.
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
|
||||
+5
-5
@@ -5,16 +5,17 @@ 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
|
||||
modernc.org/sqlite v1.37.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.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
|
||||
@@ -28,9 +29,8 @@ 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
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
@@ -20,26 +21,47 @@ type nodeCommander interface {
|
||||
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
|
||||
}
|
||||
|
||||
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
|
||||
|
||||
type Checker struct {
|
||||
repo *repo.Repository
|
||||
commander nodeCommander
|
||||
lastRun map[int64]int64
|
||||
inFlight map[int64]struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
// In-memory latest result per monitor (for real-time API reads)
|
||||
latestResults map[int64]*model.ServiceMonitorResult
|
||||
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
|
||||
|
||||
mu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
|
||||
}
|
||||
|
||||
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
|
||||
return &Checker{
|
||||
repo: repo,
|
||||
commander: commander,
|
||||
lastRun: make(map[int64]int64),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
repo: repo,
|
||||
commander: commander,
|
||||
lastRun: make(map[int64]int64),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
latestResults: make(map[int64]*model.ServiceMonitorResult),
|
||||
lastDBWrite: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLatestCached returns the in-memory latest results (updated every 1s).
|
||||
// Returns nil if no results are cached.
|
||||
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
|
||||
for _, r := range c.latestResults {
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (c *Checker) Start(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
@@ -52,7 +74,7 @@ func (c *Checker) Start(ctx context.Context) {
|
||||
limits := c.loadServiceMonitorLimits()
|
||||
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
|
||||
if scanInterval <= 0 {
|
||||
scanInterval = 30 * time.Second
|
||||
scanInterval = 1 * time.Second
|
||||
}
|
||||
|
||||
timer := time.NewTimer(scanInterval)
|
||||
@@ -87,6 +109,12 @@ func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult,
|
||||
}
|
||||
|
||||
func (c *Checker) runChecks(ctx context.Context) {
|
||||
// Skip if previous round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&c.checking, 0)
|
||||
|
||||
if c == nil || c.repo == nil {
|
||||
return
|
||||
}
|
||||
@@ -174,6 +202,8 @@ func (c *Checker) runChecks(ctx context.Context) {
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
|
||||
|
||||
for i := 0; i < workerLimit; i++ {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
@@ -188,14 +218,26 @@ func (c *Checker) runChecks(ctx context.Context) {
|
||||
}
|
||||
ts := time.Now().UnixMilli()
|
||||
result := c.executeCheck(&m, ts, limits)
|
||||
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
|
||||
}
|
||||
|
||||
// Always update in-memory cache for real-time reads
|
||||
c.mu.Lock()
|
||||
c.latestResults[m.ID] = result
|
||||
c.lastRun[m.ID] = result.Timestamp
|
||||
delete(c.inFlight, m.ID)
|
||||
|
||||
// Only write to DB every 30s per monitor
|
||||
lastWrite := c.lastDBWrite[m.ID]
|
||||
writeToDB := ts-lastWrite >= reportIntervalMs
|
||||
if writeToDB {
|
||||
c.lastDBWrite[m.ID] = ts
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if writeToDB {
|
||||
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
|
||||
@@ -280,6 +280,16 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -308,6 +318,12 @@ 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)
|
||||
}
|
||||
@@ -1561,10 +1577,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
}
|
||||
var serviceAddr string
|
||||
if bindIP != "" {
|
||||
if strings.Contains(bindIP, ":") {
|
||||
serviceAddr = processServerAddress(bindIP)
|
||||
trimmedBindIP := strings.TrimSpace(bindIP)
|
||||
if _, _, err := net.SplitHostPort(trimmedBindIP); err == nil {
|
||||
serviceAddr = processServerAddress(trimmedBindIP)
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port))
|
||||
serviceAddr = processServerAddress(net.JoinHostPort(strings.Trim(trimmedBindIP, "[]"), strconv.Itoa(port)))
|
||||
}
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port))
|
||||
|
||||
@@ -421,6 +421,63 @@ 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,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
|
||||
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
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_Incompatible_Reverse(t *testing.T) {
|
||||
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 incompatible nodes (v6-only -> v4-only)")
|
||||
t.Fatal("expected error for nodes with no IP addresses")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type userTunnelPolicy struct {
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
Status int
|
||||
Num int
|
||||
}
|
||||
|
||||
type gostConfigSnapshot struct {
|
||||
@@ -363,6 +364,16 @@ 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
|
||||
@@ -392,6 +403,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -442,7 +463,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,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,14 @@ 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
|
||||
@@ -42,8 +44,12 @@ type Handler struct {
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -64,6 +70,10 @@ 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"`
|
||||
@@ -93,6 +103,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.qualityProber = newTunnelQualityProber(h)
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||
metricInfo := metrics.SystemInfo{
|
||||
@@ -132,6 +143,7 @@ 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)
|
||||
@@ -229,6 +241,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
|
||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||
@@ -782,6 +795,94 @@ 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
|
||||
}
|
||||
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
if err := h.repo.UpsertConfig("license_expiry", expiry, 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("请求失败"))
|
||||
@@ -798,6 +899,14 @@ 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)
|
||||
@@ -805,6 +914,11 @@ 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()))
|
||||
@@ -837,13 +951,19 @@ 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()))
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" {
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" && name != "app_bg_image" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
@@ -882,11 +1002,32 @@ 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("请求失败"))
|
||||
@@ -1396,15 +1537,22 @@ func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
"update_time": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
updateTime := ann.CreatedTime
|
||||
if ann.UpdatedTime.Valid {
|
||||
updateTime = ann.UpdatedTime.Int64
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
"update_time": updateTime,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/license"
|
||||
)
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
@@ -18,7 +20,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(5)
|
||||
h.jobsWG.Add(7)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
@@ -26,6 +28,60 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
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)
|
||||
} else {
|
||||
now := time.Now().UnixMilli()
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -63,6 +119,15 @@ 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
|
||||
}
|
||||
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ type monitorNodeListItem struct {
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Version string `json:"version"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
@@ -100,6 +101,7 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -207,6 +209,99 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
|
||||
response.WriteJSON(w, response.OK(metric))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first
|
||||
if h.qualityProber != nil {
|
||||
items := h.qualityProber.GetAll()
|
||||
if len(items) > 0 {
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database (latest per tunnel)
|
||||
qualities, err := h.repo.GetLatestTunnelQualities()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
snapshots = append(snapshots, tunnelQualitySnapshot{
|
||||
TunnelID: q.TunnelID,
|
||||
EntryToExitLatency: q.EntryToExitLatency,
|
||||
ExitToBingLatency: q.ExitToBingLatency,
|
||||
EntryToExitLoss: q.EntryToExitLoss,
|
||||
ExitToBingLoss: q.ExitToBingLoss,
|
||||
Success: q.Success == 1,
|
||||
ErrorMessage: q.ErrorMessage,
|
||||
Timestamp: q.Timestamp,
|
||||
ChainDetails: q.ChainDetails,
|
||||
})
|
||||
}
|
||||
response.WriteJSON(w, response.OK(snapshots))
|
||||
}
|
||||
|
||||
// monitorTunnelQualityHistory returns quality probe history for charting.
|
||||
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
|
||||
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
|
||||
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 || endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -216,7 +311,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/tunnels/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/quality
|
||||
if strings.HasSuffix(rest, "/quality") {
|
||||
h.monitorTunnelQualityHistory(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
|
||||
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
@@ -587,6 +698,30 @@ func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().Get("end")
|
||||
if startStr != "" && endStr != "" {
|
||||
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
|
||||
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: count-based limit query (backward compat).
|
||||
limit := 100
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
|
||||
@@ -612,6 +747,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first (updated every 1s)
|
||||
if h.healthCheck != nil {
|
||||
cached := h.healthCheck.GetLatestCached()
|
||||
if len(cached) > 0 {
|
||||
response.WriteJSON(w, response.OK(cached))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database
|
||||
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
|
||||
@@ -453,7 +453,16 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
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)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -769,14 +778,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
ipPreference := asString(req["ipPreference"])
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
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)
|
||||
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -795,6 +797,14 @@ 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,
|
||||
@@ -1102,24 +1112,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := pickForwardPortFromRecords(oldPorts)
|
||||
if port <= 0 {
|
||||
referencePort := pickForwardPortFromRecords(oldPorts)
|
||||
if referencePort <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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: ""})
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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("请求失败"))
|
||||
@@ -1468,6 +1567,12 @@ 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
|
||||
@@ -2202,13 +2307,24 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
||||
nd, ndErr := h.getNodeRecord(nid)
|
||||
if ndErr != nil {
|
||||
portRangeErr = ndErr
|
||||
continue
|
||||
portRangeOk = false
|
||||
break
|
||||
}
|
||||
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++
|
||||
@@ -2394,14 +2510,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.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())
|
||||
}
|
||||
@@ -2445,9 +2565,12 @@ 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 {
|
||||
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
var revokeErr error
|
||||
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2456,6 +2579,9 @@ 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())
|
||||
}
|
||||
|
||||
@@ -2618,7 +2744,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if excludeTunnelID > 0 {
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
} else {
|
||||
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2653,7 +2783,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if excludeTunnelID > 0 {
|
||||
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
} else {
|
||||
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2675,6 +2809,22 @@ 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 {
|
||||
@@ -2693,7 +2843,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
if node.IsRemote != 1 && node.Status != 1 {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
// 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("部分节点不在线")
|
||||
}
|
||||
}
|
||||
state.Nodes[nodeID] = node
|
||||
}
|
||||
@@ -3093,7 +3248,6 @@ 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]
|
||||
@@ -3103,7 +3257,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 node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||
@@ -3117,7 +3271,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
}
|
||||
for _, chainNode := range hop {
|
||||
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||
@@ -3125,12 +3280,18 @@ 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)
|
||||
@@ -3138,11 +3299,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, outNode := range state.OutNodes {
|
||||
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[outNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 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)
|
||||
@@ -3233,6 +3398,13 @@ 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 {
|
||||
@@ -3359,6 +3531,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 同版本优先
|
||||
if fromV4 && toV4 {
|
||||
if host := pickNodeAddressV4(toNode); host != "" {
|
||||
return host, nil
|
||||
@@ -3369,6 +3542,17 @@ 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)
|
||||
}
|
||||
@@ -3477,7 +3661,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.PickNodePortTx(tx, nodeID, allocated, 0)
|
||||
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -3507,7 +3691,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.PickNodePortTx(tx, nodeID, allocated, 0)
|
||||
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -3911,6 +4095,27 @@ 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
|
||||
|
||||
@@ -35,6 +35,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
var skippedParse, skippedZero int
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
@@ -42,9 +43,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
skippedParse++
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
skippedZero++
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
@@ -53,6 +56,9 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
if len(items) > 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,6 +73,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,5 +114,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
} else {
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
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"`
|
||||
EntryToExitLatency float64 `json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `json:"exitToBingLoss"`
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
|
||||
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
|
||||
type tunnelQualityProber struct {
|
||||
handler *Handler
|
||||
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
interval time.Duration
|
||||
lastPrune int64
|
||||
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
|
||||
}
|
||||
|
||||
// newTunnelQualityProber creates a new prober (not yet running).
|
||||
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
interval: tunnelQualityProbeInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background probe loop (call from jobs.go).
|
||||
func (p *tunnelQualityProber) Start(ctx context.Context) {
|
||||
// Use the provided context so we stop with other background jobs.
|
||||
p.ctx, p.cancel = context.WithCancel(ctx)
|
||||
p.loop()
|
||||
}
|
||||
|
||||
// Stop halts the background probe loop.
|
||||
func (p *tunnelQualityProber) Stop() {
|
||||
if p == nil || p.cancel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
// GetAll returns all cached quality snapshots (latest per tunnel).
|
||||
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
|
||||
var items []tunnelQualitySnapshot
|
||||
p.cache.Range(func(_, value interface{}) bool {
|
||||
if snap, ok := value.(*tunnelQualitySnapshot); ok {
|
||||
items = append(items, *snap)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) loop() {
|
||||
// Initial delay to let the system boot up
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Run once immediately
|
||||
p.probeAll()
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.probeAll()
|
||||
p.maybePrune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
p.lastPrune = now
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
|
||||
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
|
||||
log.Printf("tunnel_quality_prober: prune err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
defer atomic.StoreInt32(&p.probing, 0)
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
|
||||
if err != nil {
|
||||
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
|
||||
return
|
||||
}
|
||||
if len(tunnelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Probe tunnels concurrently with a worker limit
|
||||
// (mirrors health.Checker worker pool pattern)
|
||||
const maxWorkers = 20
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(tid int64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
p.probeTunnel(tid)
|
||||
}(tunnelID)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snap := &tunnelQualitySnapshot{
|
||||
TunnelID: tunnelID,
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
// Get tunnel chain info
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
snap.ErrorMessage = "隧道不存在"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil || len(chainRows) == 0 {
|
||||
snap.ErrorMessage = "隧道配置不完整"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)
|
||||
|
||||
options := diagnosisExecOptions{
|
||||
commandTimeout: tunnelQualityProbeTimeout,
|
||||
pingTimeoutMS: tunnelQualityPingTimeoutMs,
|
||||
timeoutMessage: "探测超时",
|
||||
}
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
// Port forwarding: entry → Bing only
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
// Tunnel forwarding: entry → exit + exit → Bing
|
||||
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()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit → Bing
|
||||
if len(outNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
} else {
|
||||
if snap.ErrorMessage == "" {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
probeOK = false
|
||||
}
|
||||
}
|
||||
|
||||
snap.Success = probeOK
|
||||
default:
|
||||
// Unknown type: entry → Bing
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
|
||||
h := p.handler
|
||||
if h == nil {
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
return 0, 100, nodeErr
|
||||
}
|
||||
|
||||
var pingData map[string]interface{}
|
||||
var pingErr error
|
||||
if node != nil && node.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
|
||||
}
|
||||
if pingErr != nil {
|
||||
return 0, 100, pingErr
|
||||
}
|
||||
|
||||
avgTime := asFloat(pingData["averageTime"], 0)
|
||||
packetLoss := asFloat(pingData["packetLoss"], 100)
|
||||
|
||||
return avgTime, packetLoss, nil
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
|
||||
if snap == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory cache (latest per tunnel)
|
||||
// Retain the lastDBWrite timestamp if it exists, so we only DB write every 30s
|
||||
var lastWrite int64
|
||||
if existing, ok := p.cache.Load(snap.TunnelID); ok {
|
||||
if eg, ok := existing.(*tunnelQualitySnapshot); ok {
|
||||
lastWrite = eg.lastDBWrite
|
||||
}
|
||||
}
|
||||
snap.lastDBWrite = lastWrite
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
writeToDB := false
|
||||
if now-snap.lastDBWrite >= int64(tunnelQualityReportInterval/time.Millisecond) {
|
||||
writeToDB = true
|
||||
snap.lastDBWrite = now
|
||||
}
|
||||
|
||||
p.cache.Store(snap.TunnelID, snap)
|
||||
|
||||
if !writeToDB {
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to database (history)
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
successInt := 0
|
||||
if snap.Success {
|
||||
successInt = 1
|
||||
}
|
||||
|
||||
q := &model.TunnelQuality{
|
||||
TunnelID: snap.TunnelID,
|
||||
EntryToExitLatency: snap.EntryToExitLatency,
|
||||
ExitToBingLatency: snap.ExitToBingLatency,
|
||||
EntryToExitLoss: snap.EntryToExitLoss,
|
||||
ExitToBingLoss: snap.ExitToBingLoss,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubProxy = "https://gcode.hostcentral.cc"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
@@ -23,6 +22,9 @@ const (
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
|
||||
defaultGithubProxyEnabled = true
|
||||
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,6 +72,39 @@ 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
|
||||
@@ -149,14 +184,8 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
@@ -213,14 +242,8 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
|
||||
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
|
||||
|
||||
type upgradeResult struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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"`
|
||||
Attributes struct {
|
||||
Expiry string `json:"expiry"`
|
||||
} `json:"attributes"`
|
||||
} `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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, 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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, 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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, 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, 0, now+1000)
|
||||
metrics, err := svc.GetMetrics(1, now-60000, 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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, 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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, 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, 0, time.Now().UnixMilli()+1000)
|
||||
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ const (
|
||||
|
||||
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
|
||||
return ServiceMonitorLimits{
|
||||
CheckerScanIntervalSec: 30,
|
||||
WorkerLimit: 5,
|
||||
MinIntervalSec: 30,
|
||||
DefaultIntervalSec: 60,
|
||||
CheckerScanIntervalSec: 1,
|
||||
WorkerLimit: 20,
|
||||
MinIntervalSec: 1,
|
||||
DefaultIntervalSec: 1,
|
||||
MinTimeoutSec: 1,
|
||||
DefaultTimeoutSec: 5,
|
||||
MaxTimeoutSec: 60,
|
||||
|
||||
@@ -675,9 +675,9 @@ func (NodeMetric) TableName() string { return "node_metric" }
|
||||
|
||||
type TunnelMetric struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
|
||||
Timestamp int64 `gorm:"not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
|
||||
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
|
||||
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
|
||||
Connections int64 `gorm:"column:connections" json:"connections"`
|
||||
@@ -714,3 +714,21 @@ type ServiceMonitorResult struct {
|
||||
}
|
||||
|
||||
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
|
||||
|
||||
// TunnelQuality stores periodic probe results for a tunnel.
|
||||
// Unlike the old upsert model, rows accumulate for history/charting.
|
||||
// Old rows are pruned periodically (default: keep 24h).
|
||||
type TunnelQuality struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
|
||||
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
|
||||
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" }
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -51,6 +53,7 @@ type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
type TunnelQuality = model.TunnelQuality
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +194,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
&model.TunnelQuality{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
@@ -675,9 +679,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"version": nullableString(n.Version),
|
||||
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
||||
"status": n.Status, "isRemote": n.IsRemote,
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -3238,7 +3244,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
||||
pair := formatForwardIngressAddress(ip, row.Port.Int64)
|
||||
if _, ok := seenPairs[pair]; !ok {
|
||||
seenPairs[pair] = struct{}{}
|
||||
entries = append(entries, pair)
|
||||
@@ -3255,6 +3261,17 @@ 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
|
||||
@@ -3317,15 +3334,60 @@ 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.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]
|
||||
}
|
||||
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)
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
@@ -3419,10 +3481,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("connections + excluded.connections"),
|
||||
"errors": gorm.Expr("errors + excluded.errors"),
|
||||
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
|
||||
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
|
||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||
}),
|
||||
}).CreateInBatches(rows, 100).Error
|
||||
@@ -3566,6 +3628,25 @@ func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]mod
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs].
|
||||
// Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting.
|
||||
func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var results []model.ServiceMonitorResult
|
||||
err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs).
|
||||
Order("timestamp DESC").
|
||||
Limit(5000).
|
||||
Find(&results).Error
|
||||
if len(results) > 1 {
|
||||
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
|
||||
// This is intended for list rendering (avoid N+1 queries).
|
||||
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
|
||||
|
||||
@@ -80,6 +80,37 @@ 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")
|
||||
@@ -231,6 +262,24 @@ 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,8 @@ 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", "updated_time").
|
||||
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
|
||||
Where("is_remote = ?", 0).
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
return nodes, err
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -453,6 +455,14 @@ 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")
|
||||
}
|
||||
@@ -505,6 +515,7 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
||||
}
|
||||
}
|
||||
|
||||
var available []int
|
||||
for _, candidate := range candidates {
|
||||
if candidate <= 0 {
|
||||
continue
|
||||
@@ -512,11 +523,25 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
||||
if _, ok := used[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
allocated[nodeID] = candidate
|
||||
return candidate, nil
|
||||
available = append(available, candidate)
|
||||
}
|
||||
|
||||
return 0, errors.New("节点端口已满,无可用端口")
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelIPPreference(tunnelID int64) string {
|
||||
@@ -997,9 +1022,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
|
||||
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) 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) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||
for _, uid := range currentUserIDs {
|
||||
@@ -1018,7 +1050,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
}
|
||||
}
|
||||
if len(removedUserIDs) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1026,6 +1058,8 @@ 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{}).
|
||||
@@ -1033,7 +1067,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 err
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1046,28 +1080,32 @@ 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 err
|
||||
return revoked, 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 err
|
||||
return revoked, 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 err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1080,7 +1118,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 err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1092,22 +1130,27 @@ 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 err
|
||||
return nil, 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 err
|
||||
return revoked, 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 err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// InsertTunnelQuality appends a tunnel quality probe result.
|
||||
// (Follows the same pattern as InsertServiceMonitorResult.)
|
||||
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if q == nil || q.TunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(q).Error
|
||||
}
|
||||
|
||||
// GetTunnelQualityHistory returns quality probe results for a tunnel
|
||||
// within a time range, ordered by timestamp ascending.
|
||||
// (Mirrors GetServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var results []model.TunnelQuality
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp ASC").
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
|
||||
// (Mirrors GetLatestServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.TunnelQuality
|
||||
|
||||
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
||||
q := `
|
||||
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
||||
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM tunnel_quality
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY tunnel_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback for older SQLite
|
||||
results = nil
|
||||
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.TunnelQuality, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.TunnelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.TunnelID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
|
||||
// (Mirrors PruneServiceMonitorResults pattern.)
|
||||
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
|
||||
}
|
||||
|
||||
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
|
||||
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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, 0, now+1000)
|
||||
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,21 +50,18 @@ 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 failure when nodes are offline")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "节点") {
|
||||
t.Fatalf("expected node-related error, got %q", out.Msg)
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected create success (runtime deferred when nodes offline), got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
|
||||
if tunnelCount != 0 {
|
||||
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
|
||||
if tunnelCount != 1 {
|
||||
t.Fatalf("expected tunnel record preserved (runtime deferred), found %d records", tunnelCount)
|
||||
}
|
||||
|
||||
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
|
||||
if chainCount != 0 {
|
||||
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
|
||||
if chainCount != 3 {
|
||||
t.Fatalf("expected 3 chain_tunnel records preserved (in/chain/out), found %d records", chainCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
|
||||
+3
-1
@@ -119,7 +119,9 @@ func main() {
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
||||
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)
|
||||
defer wsReporter.Stop()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X API KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||
|
||||
|
||||
@@ -624,6 +624,11 @@ func resumeService(ctx *gin.Context) {
|
||||
existingSvc.Close()
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -1039,8 +1044,13 @@ func resumeServices(ctx *gin.Context) {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GOST CONNECTOR KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Connection initiators (clients) for various protocols in GOST forwarding.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GO-GOST REGISTRY KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Wed Feb 04 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# GOST SOCKET KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
WebSocket reporter and socket utilities for panel integration.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
}
|
||||
@@ -397,8 +397,13 @@ func resumeServices(req resumeServicesRequest) error {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -190,7 +190,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
||||
return &WebSocketReporter{
|
||||
url: serverURL,
|
||||
curBackoff: initialBackoff, // 当前退避间隔
|
||||
pingInterval: 5 * time.Second, // 指标上报间隔
|
||||
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
|
||||
configInterval: 10 * time.Minute, // 配置上报间隔
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -348,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
||||
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
|
||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
schemes := []string{"wss", "ws"}
|
||||
|
||||
+69
-5
@@ -25,10 +25,62 @@ get_architecture() {
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
|
||||
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 "==============================================="
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -81,9 +133,14 @@ build_download_url() {
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
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)")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -210,6 +267,10 @@ done
|
||||
# 安装功能
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
|
||||
ask_proxy_config
|
||||
ensure_download_url_initialized || exit 1
|
||||
|
||||
get_config_params
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
@@ -308,6 +369,9 @@ update_flux_agent() {
|
||||
echo "❌ flux_agent 未安装,请先选择安装。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ask_proxy_config
|
||||
ensure_download_url_initialized || return 1
|
||||
|
||||
echo "📥 使用下载地址: $DOWNLOAD_URL"
|
||||
|
||||
|
||||
+71
-5
@@ -13,10 +13,62 @@ REPO="Sagit-chu/flux-panel"
|
||||
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
|
||||
PINNED_VERSION=""
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
|
||||
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 "==============================================="
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -70,9 +122,14 @@ set_compose_urls_by_version() {
|
||||
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
|
||||
}
|
||||
|
||||
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
set_compose_urls_by_version "$RESOLVED_VERSION"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -374,6 +431,10 @@ get_config_params() {
|
||||
# 安装功能
|
||||
install_panel() {
|
||||
echo "🚀 开始安装面板..."
|
||||
|
||||
ask_proxy_config
|
||||
ensure_compose_urls_initialized || return 1
|
||||
|
||||
check_docker
|
||||
get_config_params
|
||||
|
||||
@@ -425,6 +486,7 @@ EOF
|
||||
# 更新功能
|
||||
update_panel() {
|
||||
echo "🔄 开始更新面板..."
|
||||
ask_proxy_config
|
||||
check_docker
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
@@ -506,6 +568,8 @@ 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"
|
||||
@@ -581,6 +645,8 @@ 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"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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. 构建通过 + 全量测试通过
|
||||
@@ -0,0 +1,31 @@
|
||||
# 055 - 隧道质量定时探测 + 实时展示 + 历史图表
|
||||
|
||||
## 背景
|
||||
当前隧道质量检测是手动触发的:用户点击"诊断"按钮 → 后端调用节点 TcpPing → 返回结果。
|
||||
需求:改为**后端定时(每10秒)自动探测**所有启用隧道的质量(入口→出口延迟、出口→Bing延迟),
|
||||
结果保留历史(24h),前端隧道 Tab 实时展示 + 图表历史趋势。
|
||||
|
||||
## 设计原则:与服务监控复用
|
||||
|
||||
| 复用点 | 服务监控 | 隧道质量 |
|
||||
|--------|---------|---------|
|
||||
| 调度方式 | `health.Checker.Start(ctx)` via `jobs.go` | `tunnelQualityProber.Start(ctx)` via `jobs.go` |
|
||||
| 存储模式 | `service_monitor_result` (history, insert) | `tunnel_quality` (history, insert) |
|
||||
| 清理方式 | `PruneServiceMonitorResults(olderThanMs)` | `PruneTunnelQualityResults(olderThanMs)` |
|
||||
| 最新查询 | `GetLatestServiceMonitorResults()` (window func) | `GetLatestTunnelQualities()` (window func) |
|
||||
| 历史查询 | `GetServiceMonitorResults(id, limit)` | `GetTunnelQualityHistory(id, start, end)` |
|
||||
| API 模式 | `GET /monitor/services/{id}/results` | `GET /monitor/tunnels/{id}/quality` |
|
||||
| 前端图表 | Recharts LineChart (延迟趋势) | Recharts LineChart (同样模式) |
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. `TunnelQuality` model 改为历史存储(composite index, 非 unique)
|
||||
- [x] 2. Repo 改为 insert(非 upsert),复用服务监控的查询模式
|
||||
- [x] 3. 添加 `PruneTunnelQualityResults` + `GetLatestTunnelQualities` + `GetTunnelQualityHistory`
|
||||
- [x] 4. Prober 生命周期集成到 `jobs.go`(与 healthCheck 同级)
|
||||
- [x] 5. Prober 添加 24h 清理周期
|
||||
- [x] 6. 添加 API `GET /monitor/tunnels/{id}/quality` 返回历史
|
||||
- [x] 7. 前端添加 `getMonitorTunnelQualityHistory()` API
|
||||
- [x] 8. 前端详情页添加质量趋势图表(复用服务监控图表组件模式)
|
||||
- [x] 9. Go 编译 + 测试通过
|
||||
- [x] 10. TypeScript 编译通过
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tunnel Quality Uptime Kuma Display
|
||||
**Plan ID**: 056-tunnel-uptime-kuma-bars.md
|
||||
|
||||
## Objective
|
||||
The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars.
|
||||
|
||||
## Steps
|
||||
- [x] Remove the individual "Quality" column from the tunnel monitor table view.
|
||||
- [x] Remove the individual "Quality" chip from the tunnel monitor grid view.
|
||||
- [x] Implement `<UptimeHistoryBar />` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger).
|
||||
- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty.
|
||||
- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately.
|
||||
- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility.
|
||||
|
||||
## Status
|
||||
Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 057 Hide Remote Nodes from Monitor View
|
||||
|
||||
## Goal
|
||||
Do not display remote nodes on the Monitoring page.
|
||||
|
||||
## Changes Made
|
||||
- Modified `ListMonitorNodes` in `go-backend/internal/store/repo/repository_monitor_nodes.go` by adding a `.Where("is_remote = ?", 0)` constraint so that remote nodes (imported via Federation feature) are entirely excluded from the returned payload for API `/api/v1/monitor/nodes`.
|
||||
- This efficiently removes remote nodes from both the grid/list displaying Node stats in the Monitoring tab and also eliminates remote nodes from the selection dropdown when creating new Service Monitors.
|
||||
|
||||
## Checklist
|
||||
- [x] Identify how "remote node" is defined in the database structure (`IsRemote` = 1 or 0).
|
||||
- [x] Add SQL query constraint to filter out remote nodes from the `/monitor/nodes` API response.
|
||||
- [x] Verify changes compile successfully.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plan 058: Sync Main and Push Tag
|
||||
|
||||
Sync current changes to `main`, merge to `master` (if needed) or just PR/merge on `main` (if that's the flow), and push a new tag `2.1.9-beta7`.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Determine the next tag version (`2.1.9-beta7`).
|
||||
- [x] Create a feature branch `release/2.1.9-beta7`.
|
||||
- [x] Commit all current changes to the feature branch.
|
||||
- [x] Push the feature branch to remote.
|
||||
- [x] Create a PR to `main`.
|
||||
- [x] Merge the PR to `main`.
|
||||
- [x] Create and push tag `2.1.9-beta7`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 059 - 主题系统设计(v2 — 完整可扩展架构)
|
||||
|
||||
## 概述
|
||||
|
||||
设计一个高度可扩展的主题包架构,允许第三方作者通过代码提交的方式创建主题,覆盖前端所有元素——从 CSS 变量到组件实现、布局结构、甚至整个页面。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
src/themes/
|
||||
├── types.ts # ThemePackage 接口定义
|
||||
├── registry.ts # 主题注册表 + CSS 注入引擎
|
||||
├── context.tsx # React Context + Provider + Hooks
|
||||
├── index.ts # 公共 API barrel
|
||||
├── loader.ts # 主题加载器(注册所有内置主题)
|
||||
├── README.md # 主题开发指南
|
||||
│
|
||||
├── default/ # 默认主题(参考实现)
|
||||
│ └── index.ts
|
||||
│
|
||||
├── example-cyberpunk/ # 示例主题(赛博朋克)
|
||||
│ ├── index.ts
|
||||
│ └── components/
|
||||
│ └── button.tsx # 组件覆盖示范
|
||||
│
|
||||
└── <your-theme>/ # 第三方主题
|
||||
├── index.ts
|
||||
├── components/
|
||||
├── layouts/
|
||||
├── pages/
|
||||
└── assets/
|
||||
```
|
||||
|
||||
## 覆盖层级
|
||||
|
||||
| 层级 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| CSS 变量 | `tokens.light` / `tokens.dark` | 80+ 个设计 token(颜色、字体、圆角) |
|
||||
| 原始 CSS | `css` | 注入自定义 CSS(动画、字体、阴影等) |
|
||||
| 组件替换 | `components` | 替换任意 UI 组件(30+ 个可替换组件键) |
|
||||
| 布局替换 | `layouts` | 替换 4 种布局(Admin / H5 / H5Simple / Default) |
|
||||
| 页面替换 | `pages` | 替换 14 个页面路由实现 |
|
||||
| 生命周期 | `onActivate` / `onDeactivate` | 主题启用/停用回调 |
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] **T1**: 创建 `src/themes/types.ts` — ThemePackage 接口 + 所有可覆盖键定义
|
||||
- [x] **T2**: 创建 `src/themes/registry.ts` — 主题注册/激活/停用/CSS 注入引擎
|
||||
- [x] **T3**: 创建 `src/themes/context.tsx` — React Context + ThemeProvider + hooks
|
||||
- [x] **T4**: 创建 `src/themes/index.ts` — 公共 API barrel
|
||||
- [x] **T5**: 创建 `src/themes/loader.ts` — 自动加载所有内置主题
|
||||
- [x] **T6**: 创建 `src/themes/default/` — 默认主题参考实现
|
||||
- [x] **T7**: 创建 `src/themes/example-cyberpunk/` — 完整示例主题(含组件覆盖 + CSS + 生命周期)
|
||||
- [x] **T8**: 重构 `use-theme.tsx` — 向后兼容包装
|
||||
- [x] **T9**: 重构 `theme-provider.tsx` — 集成新主题系统
|
||||
- [x] **T10**: 编写 `README.md` — 主题开发完整指南
|
||||
- [x] **T11**: TypeScript 编译验证通过
|
||||
- [ ] **T12**: (后续) 设置页面集成主题选择器 UI
|
||||
- [ ] **T13**: (后续) 将现有组件导入逐步迁移到 `useThemedComponent` 模式
|
||||
@@ -0,0 +1,42 @@
|
||||
# 060 Nezha-style Monitoring (1s test, 30s report)
|
||||
|
||||
## Objective
|
||||
Update all monitoring subsystems to test every 1 second and report (write to DB) every 30 seconds, matching Nezha-style monitoring behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Tunnel Quality Prober (`go-backend/internal/http/handler/tunnel_quality_prober.go`)
|
||||
- [x] Change `tunnelQualityProbeInterval` from 10s to 1s
|
||||
- [x] Add `tunnelQualityReportInterval = 30s` for DB write throttling
|
||||
- [x] Update `storeResult` to cache in-memory every tick, write to DB only every 30s per tunnel
|
||||
- [x] Add atomic `probing` flag to prevent overlapping `probeAll()` goroutine pile-up
|
||||
- [x] Increase `maxWorkers` from 4 to 20
|
||||
|
||||
### 2. Service Monitor Checker (`go-backend/internal/health/checker.go`)
|
||||
- [x] Add `serviceMonitorReportInterval = 30s` for DB write throttling
|
||||
- [x] Add `latestResults` in-memory map and `lastDBWrite` map per monitor
|
||||
- [x] Add `GetLatestCached()` method for real-time API reads
|
||||
- [x] Add atomic `checking` flag to prevent overlapping `runChecks()` goroutine pile-up
|
||||
- [x] Modify worker goroutines to always update in-memory cache, only write to DB every 30s
|
||||
|
||||
### 3. Service Monitor Limits (`go-backend/internal/monitoring/limits.go`)
|
||||
- [x] Change `CheckerScanIntervalSec` default from 30 to 1
|
||||
- [x] Change `WorkerLimit` default from 5 to 20
|
||||
- [x] Change `MinIntervalSec` default from 30 to 1
|
||||
- [x] Change `DefaultIntervalSec` default from 60 to 1
|
||||
|
||||
### 4. Monitoring API Handler (`go-backend/internal/http/handler/monitoring.go`)
|
||||
- [x] Update `monitorServiceLatestResultsHandler` to prefer in-memory cached results from `healthCheck.GetLatestCached()`
|
||||
|
||||
### 5. Agent WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`)
|
||||
- [x] Change `pingInterval` (metric reporting) from 5s to 1s
|
||||
|
||||
### 6. Frontend - Tunnel Monitor (`vite-frontend/src/pages/node/tunnel-monitor-view.tsx`)
|
||||
- [x] Change `QUALITY_POLL_INTERVAL` from 10s to 1s
|
||||
- [x] Update detail view text: "自动探测中(每秒测试,30秒上报)"
|
||||
- [x] Update list view text: "每秒探测 · 更新于 ..."
|
||||
|
||||
### 7. Frontend - Service Monitor (`vite-frontend/src/pages/node/monitor-view.tsx`)
|
||||
- [x] Change `DEFAULT_SERVICE_MONITOR_LIMITS` defaults to match backend (1s intervals)
|
||||
- [x] Change service monitor + latest results polling from 30s to 1s
|
||||
- [x] Update info bar text: "每秒测试,30秒上报"
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 066 - Issue 349 IPv6 Entry Format
|
||||
|
||||
## Goal
|
||||
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
|
||||
|
||||
## Checklist
|
||||
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
|
||||
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
|
||||
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
|
||||
- [x] 运行相关测试并确认通过。
|
||||
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@flvx/skill-api",
|
||||
"version": "2.1.5",
|
||||
"version": "2.1.9-rc4",
|
||||
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
|
||||
"keywords": [
|
||||
"opencode",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/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,9 +1,9 @@
|
||||
# VITE FRONTEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.5-rc15
|
||||
**Tag:** 2.1.9-beta6
|
||||
|
||||
## OVERVIEW
|
||||
Web management console for FLVX.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
@@ -85,11 +88,22 @@
|
||||
shortcutIconLink.removeAttribute('type');
|
||||
}
|
||||
|
||||
// 立即检测系统主题并应用
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// 立即检测主题并应用(优先读取 localStorage 中的用户偏好)
|
||||
var savedMode = '';
|
||||
try { savedMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
|
||||
|
||||
var useDark;
|
||||
if (savedMode === 'dark') {
|
||||
useDark = true;
|
||||
} else if (savedMode === 'light') {
|
||||
useDark = false;
|
||||
} else {
|
||||
// "system" 或无保存:跟随系统
|
||||
useDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// 立即设置主题类,避免闪烁
|
||||
if (isDarkMode) {
|
||||
if (useDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
@@ -97,9 +111,13 @@
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
// 仅在"跟随系统"模式下监听系统主题变化
|
||||
if ((!savedMode || savedMode === 'system') && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
// 运行时可能已由 React 接管,这里仅做 fallback
|
||||
var currentMode = '';
|
||||
try { currentMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
|
||||
if (currentMode && currentMode !== 'system') return; // 用户已手动选择,不干预
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,10 +45,18 @@ http {
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# HTML 文件不缓存
|
||||
location ~* \.(?:htm|html)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Vue Router的history模式支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"tw-animate-css": "^1.3.0"
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "1.2.8",
|
||||
|
||||
+127
-103
@@ -1,5 +1,6 @@
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
|
||||
import IndexPage from "@/pages/index";
|
||||
import ChangePasswordPage from "@/pages/change-password";
|
||||
@@ -45,7 +46,7 @@ const ProtectedRoute = ({
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
|
||||
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200" />
|
||||
</div>
|
||||
);
|
||||
@@ -77,7 +78,7 @@ const LoginRoute = () => {
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
|
||||
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200" />
|
||||
</div>
|
||||
);
|
||||
@@ -87,6 +88,27 @@ const LoginRoute = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
// 处理自定义背景图片
|
||||
useEffect(() => {
|
||||
const updateBg = () => {
|
||||
const customBg = siteConfig.app_bg_image;
|
||||
if (customBg) {
|
||||
document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
|
||||
document.documentElement.classList.add("has-custom-bg");
|
||||
} else {
|
||||
document.documentElement.style.removeProperty("--custom-bg-image");
|
||||
document.documentElement.classList.remove("has-custom-bg");
|
||||
}
|
||||
};
|
||||
updateBg();
|
||||
window.addEventListener("site-config-updated", updateBg);
|
||||
return () => {
|
||||
window.removeEventListener("site-config-updated", updateBg);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 立即设置页面标题(使用已从缓存读取的配置)
|
||||
useEffect(() => {
|
||||
document.title = siteConfig.name;
|
||||
@@ -105,106 +127,108 @@ function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<LoginRoute />} path="/" />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute skipLayout={true}>
|
||||
<ChangePasswordPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/change-password"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/dashboard"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MonitorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/monitor"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ForwardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/forward"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TunnelPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/tunnel"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<NodePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/node"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/user"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<GroupPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/group"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/profile"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<LimitPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/limit"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/config"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/panel-sharing"
|
||||
/>
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
</Routes>
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes key={location.pathname} location={location}>
|
||||
<Route element={<LoginRoute />} path="/" />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute skipLayout={true}>
|
||||
<ChangePasswordPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/change-password"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/dashboard"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MonitorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/monitor"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ForwardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/forward"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TunnelPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/tunnel"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<NodePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/node"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/user"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<GroupPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/group"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/profile"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<LimitPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/limit"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/config"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/panel-sharing"
|
||||
/>
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
MonitorTunnelApiItem,
|
||||
MonitorPermissionApiItem,
|
||||
MonitorAccessApiData,
|
||||
TunnelQualityApiItem,
|
||||
} from "./types";
|
||||
|
||||
import axios from "axios";
|
||||
@@ -133,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);
|
||||
Network.post("/tunnel/update", data, { timeout: 120_000 });
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const previewTunnelDelete = (id: number) =>
|
||||
@@ -250,6 +251,9 @@ 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);
|
||||
@@ -406,6 +410,7 @@ export const importBackup = (data: BackupImportPayload) =>
|
||||
export interface AnnouncementData {
|
||||
content: string;
|
||||
enabled: number;
|
||||
update_time?: number;
|
||||
}
|
||||
|
||||
export const getAnnouncement = () =>
|
||||
@@ -426,6 +431,7 @@ export const getNodeMetrics = (
|
||||
return Network.get<NodeMetricApiItem[]>(
|
||||
`/monitor/nodes/${nodeId}/metrics`,
|
||||
params,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -451,6 +457,25 @@ export const getTunnelMetrics = (
|
||||
export const getMonitorTunnels = () =>
|
||||
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
|
||||
|
||||
export const getMonitorTunnelQuality = () =>
|
||||
Network.get<TunnelQualityApiItem[]>("/monitor/tunnels/quality");
|
||||
|
||||
export const getMonitorTunnelQualityHistory = (
|
||||
tunnelId: number,
|
||||
start?: number,
|
||||
end?: number,
|
||||
) => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (start) params.start = String(start);
|
||||
if (end) params.end = String(end);
|
||||
|
||||
return Network.get<TunnelQualityApiItem[]>(
|
||||
`/monitor/tunnels/${tunnelId}/quality`,
|
||||
params,
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceMonitorList = () =>
|
||||
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
|
||||
|
||||
@@ -466,11 +491,22 @@ export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
|
||||
export const deleteServiceMonitor = (id: number) =>
|
||||
Network.post("/monitor/services/delete", { id });
|
||||
|
||||
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
|
||||
Network.get<ServiceMonitorResultApiItem[]>(
|
||||
export const getServiceMonitorResults = (
|
||||
monitorId: number,
|
||||
options?: { limit?: number; start?: number; end?: number },
|
||||
) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.start != null && options?.end != null) {
|
||||
params.start = String(options.start);
|
||||
params.end = String(options.end);
|
||||
} else if (options?.limit != null) {
|
||||
params.limit = String(options.limit);
|
||||
}
|
||||
return Network.get<ServiceMonitorResultApiItem[]>(
|
||||
`/monitor/services/${monitorId}/results`,
|
||||
{ limit: String(limit) },
|
||||
params,
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceMonitorLatestResults = () =>
|
||||
Network.get<ServiceMonitorResultApiItem[]>(
|
||||
|
||||
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
|
||||
inx: number;
|
||||
name: string;
|
||||
status: number;
|
||||
version?: string;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
@@ -487,3 +488,26 @@ export interface MonitorAccessApiData {
|
||||
allowed: boolean;
|
||||
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;
|
||||
exitToBingLatency: number;
|
||||
entryToExitLoss: number;
|
||||
exitToBingLoss: number;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
timestamp: number;
|
||||
chainDetails?: string;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,51 +1,28 @@
|
||||
import React, { useEffect } from "react";
|
||||
/**
|
||||
* ThemeProvider — app-level wrapper
|
||||
* =================================
|
||||
* Loads all registered themes and wraps children with the theme context.
|
||||
* Import the loader to ensure all built-in themes are registered before
|
||||
* the provider mounts.
|
||||
*/
|
||||
|
||||
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
|
||||
import React from "react";
|
||||
|
||||
// Side-effect: registers all built-in themes
|
||||
import "@/themes/loader";
|
||||
|
||||
import { ThemeProvider as ThemeContextProvider } from "@/themes/context";
|
||||
import { initThemeSystem } from "@/themes/registry";
|
||||
|
||||
// Restore saved theme SYNCHRONOUSLY at module load time — before first render.
|
||||
// This ensures the CSS tokens are injected before any component renders,
|
||||
// preventing a flash of the default theme.
|
||||
initThemeSystem();
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
// 确保主题与HTML class同步
|
||||
const updateThemeClass = (currentTheme: string) => {
|
||||
if (currentTheme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.style.colorScheme = "light";
|
||||
}
|
||||
};
|
||||
|
||||
// 始终跟随系统主题
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
if (systemTheme !== theme) {
|
||||
setTheme(systemTheme);
|
||||
}
|
||||
|
||||
// 监听主题变化
|
||||
updateThemeClass(theme);
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light";
|
||||
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleThemeChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleThemeChange);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
return <ThemeContextProvider>{children}</ThemeContextProvider>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* ThemeSettings — theme picker card for the Settings page
|
||||
* ========================================================
|
||||
* Shows:
|
||||
* • Mode toggle (light / dark / system)
|
||||
* • Grid of registered themes with preview dots
|
||||
* • "Reset to default" option
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { useThemeContext } from "@/themes/context";
|
||||
import type { ThemeMode } from "@/themes/registry";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: ThemeMode; label: string; icon: string }> = [
|
||||
{ value: "light", label: "亮色", icon: "☀️" },
|
||||
{ value: "dark", label: "暗色", icon: "🌙" },
|
||||
{ value: "system", label: "跟随系统", icon: "🖥️" },
|
||||
];
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ThemeSettings: React.FC = () => {
|
||||
const {
|
||||
themes,
|
||||
activeThemeId,
|
||||
mode,
|
||||
effectiveMode,
|
||||
switchTheme,
|
||||
resetTheme,
|
||||
setMode,
|
||||
} = useThemeContext();
|
||||
|
||||
const handleModeChange = (m: ThemeMode) => {
|
||||
setMode(m);
|
||||
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
|
||||
toast.success(`已切换为${label}模式`);
|
||||
};
|
||||
|
||||
const handleThemeSelect = (id: string) => {
|
||||
switchTheme(id);
|
||||
const theme = themes.find((t) => t.id === id);
|
||||
toast.success(`已切换主题「${theme?.name ?? id}」`);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetTheme();
|
||||
toast.success("已恢复默认主题");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-5">
|
||||
主题设置
|
||||
</h2>
|
||||
|
||||
{/* ── Mode toggle ────────────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
外观模式
|
||||
</p>
|
||||
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-600 p-1 gap-1">
|
||||
{MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
mode === opt.value
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => handleModeChange(opt.value)}
|
||||
>
|
||||
<span className="mr-1.5">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Theme grid ─────────────────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
选择主题
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
|
||||
共 {themes.length} 个可用主题
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{themes.map((theme) => {
|
||||
const isActive = activeThemeId === theme.id;
|
||||
// Pick the right token set for preview
|
||||
const previewTokens =
|
||||
effectiveMode === "dark" && theme.tokens?.dark
|
||||
? theme.tokens.dark
|
||||
: theme.tokens?.light;
|
||||
|
||||
const primary = previewTokens?.["--primary"] ?? "#2563eb";
|
||||
const secondary = previewTokens?.["--secondary"] ?? "#6366f1";
|
||||
const success = previewTokens?.["--success"] ?? "#16a34a";
|
||||
const danger = previewTokens?.["--danger"] ?? "#dc2626";
|
||||
const bg = previewTokens?.["--background"] ?? "#ffffff";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={`relative rounded-xl text-left transition-all duration-200 border-2 overflow-hidden ${
|
||||
isActive
|
||||
? "border-primary shadow-lg shadow-primary/15 scale-[1.01]"
|
||||
: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:shadow-md"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => handleThemeSelect(theme.id)}
|
||||
>
|
||||
{/* Colour strip preview */}
|
||||
<div className="flex h-8">
|
||||
<div className="flex-1" style={{ background: primary }} />
|
||||
<div className="flex-1" style={{ background: secondary }} />
|
||||
<div className="flex-1" style={{ background: success }} />
|
||||
<div className="flex-1" style={{ background: danger }} />
|
||||
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{theme.name}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-bold bg-primary/15 text-primary">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{theme.author} · v{theme.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active indicator dot */}
|
||||
{isActive && (
|
||||
<span className="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-primary shadow-sm shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Reset ──────────────────────────────────────────── */}
|
||||
{activeThemeId && activeThemeId !== "default" && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={handleReset}
|
||||
>
|
||||
↩ 恢复默认主题
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Card({ className, style, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-default-200 bg-white text-card-foreground shadow-sm dark:bg-default-50/20",
|
||||
className,
|
||||
"relative flex flex-col transition-all duration-300",
|
||||
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
||||
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
}}
|
||||
data-slot="card"
|
||||
{...props}
|
||||
/>
|
||||
@@ -57,7 +64,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("p-6 pt-0", className)}
|
||||
className={cn("p-6", className)}
|
||||
data-slot="card-content"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ function DialogOverlay({
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/30 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
@@ -57,19 +57,29 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
|
||||
"fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2",
|
||||
"flex flex-col transition-all duration-300",
|
||||
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
||||
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
}}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
|
||||
@@ -94,17 +94,19 @@ export function VersionFooter({
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ const getInitialConfig = () => {
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: "",
|
||||
app_favicon: "",
|
||||
app_bg_image: "",
|
||||
is_commercial: false,
|
||||
hide_footer_brand: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +30,10 @@ const getInitialConfig = () => {
|
||||
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
||||
const cachedAppFavicon =
|
||||
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
|
||||
const cachedAppBgImage =
|
||||
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
|
||||
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
|
||||
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
|
||||
|
||||
if (cachedAppName) {
|
||||
return {
|
||||
@@ -36,6 +43,9 @@ const getInitialConfig = () => {
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
app_bg_image: cachedAppBgImage,
|
||||
is_commercial: isCommercial,
|
||||
hide_footer_brand: hideFooterBrand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +56,9 @@ const getInitialConfig = () => {
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
app_bg_image: cachedAppBgImage,
|
||||
is_commercial: isCommercial,
|
||||
hide_footer_brand: hideFooterBrand,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,7 +128,7 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
// 获取所有配置(优先从缓存)
|
||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
// 尝试从缓存获取所有配置
|
||||
const configKeys = ["app_name", "app_logo", "app_favicon"];
|
||||
const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"];
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
let hasCachedData = false;
|
||||
|
||||
@@ -255,6 +268,10 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
resolvedConfigMap,
|
||||
"app_favicon",
|
||||
);
|
||||
const hasAppBgImage = Object.prototype.hasOwnProperty.call(
|
||||
resolvedConfigMap,
|
||||
"app_bg_image",
|
||||
);
|
||||
|
||||
const appName = hasAppName
|
||||
? String(resolvedConfigMap.app_name || "").trim()
|
||||
@@ -265,6 +282,9 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
const appFavicon = hasAppFavicon
|
||||
? String(resolvedConfigMap.app_favicon || "").trim()
|
||||
: (siteConfig.app_favicon || "").trim();
|
||||
const appBgImage = hasAppBgImage
|
||||
? String(resolvedConfigMap.app_bg_image || "").trim()
|
||||
: (siteConfig.app_bg_image || "").trim();
|
||||
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
@@ -272,8 +292,13 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
|
||||
siteConfig.app_logo = appLogo;
|
||||
siteConfig.app_favicon = appFavicon;
|
||||
siteConfig.app_bg_image = appBgImage;
|
||||
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
|
||||
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
window.dispatchEvent(new Event("site-config-updated"));
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
};
|
||||
|
||||
@@ -4,12 +4,6 @@ import { toast } from "react-hot-toast";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@/shadcn-bridge/heroui/dropdown";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -25,7 +19,7 @@ import { getMonitorAccess, updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
@@ -48,13 +42,11 @@ export default function AdminLayout({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const { isOpen, onOpenChange } = useDisclosure();
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar_collapsed") === "true",
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
|
||||
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
|
||||
@@ -196,10 +188,8 @@ export default function AdminLayout({
|
||||
|
||||
useEffect(() => {
|
||||
// 获取用户信息
|
||||
const name = getSessionName() || "Admin";
|
||||
const adminFlag = getAdminFlag();
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
|
||||
// Monitor permission is not strictly role-based; non-admin users may be
|
||||
@@ -368,13 +358,13 @@ export default function AdminLayout({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isMobile ? "min-h-screen" : "h-screen"} bg-gray-100 dark:bg-black`}
|
||||
className={`flex ${isMobile ? "min-h-screen p-0" : "h-screen p-6 gap-6"} bg-mesh-gradient overflow-hidden`}
|
||||
>
|
||||
{/* 移动端遮罩层 */}
|
||||
{isMobile && mobileMenuVisible && (
|
||||
<button
|
||||
aria-label="关闭菜单"
|
||||
className="fixed inset-0 backdrop-blur-sm bg-white/50 dark:bg-black/30 z-40"
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
|
||||
type="button"
|
||||
onClick={hideMobileMenu}
|
||||
/>
|
||||
@@ -383,36 +373,34 @@ export default function AdminLayout({
|
||||
{/* 左侧菜单栏 */}
|
||||
<aside
|
||||
className={`
|
||||
${isMobile ? "fixed" : "relative"}
|
||||
${isMobile ? "fixed h-screen top-0 left-0 rounded-r-3xl" : "relative h-full rounded-3xl"}
|
||||
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
||||
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
|
||||
bg-white dark:bg-black
|
||||
shadow-lg
|
||||
border-r border-gray-200 dark:border-gray-600
|
||||
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"}
|
||||
bg-white/60 dark:bg-zinc-900/60 backdrop-blur-3xl
|
||||
shadow-[0_10px_30px_rgba(0,0,0,0.1)]
|
||||
border border-white/80 dark:border-white/10
|
||||
z-50
|
||||
transition-all duration-300 ease-in-out
|
||||
flex flex-col
|
||||
${isMobile ? "h-screen" : "h-full"}
|
||||
${isMobile ? "top-0 left-0" : ""}
|
||||
flex flex-col flex-shrink-0
|
||||
`}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||
<BrandLogo size={28} />
|
||||
<div className="px-6 py-8 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-primary text-white">
|
||||
<BrandLogo size={24} />
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-3"}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
<h1 className="text-xl font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单导航 */}
|
||||
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
|
||||
<ul className="space-y-1">
|
||||
<nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden [scrollbar-width:none]">
|
||||
<ul className="space-y-2">
|
||||
{filteredMenuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
const isMonitor = item.path === "/monitor";
|
||||
@@ -422,15 +410,15 @@ export default function AdminLayout({
|
||||
<li key={item.path}>
|
||||
<motion.button
|
||||
className={`
|
||||
w-full flex items-center p-2 rounded-lg text-left
|
||||
relative min-h-[44px] overflow-hidden transition-colors
|
||||
w-full flex items-center p-3 rounded-2xl text-left
|
||||
relative min-h-[48px] overflow-hidden transition-colors
|
||||
${isMonitorBlocked ? "opacity-60" : ""}
|
||||
${
|
||||
isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
? "text-primary dark:text-primary-400 font-semibold"
|
||||
: isMonitorBlocked
|
||||
? "text-gray-500 dark:text-gray-400"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
? "text-gray-500 dark:text-gray-400 font-medium"
|
||||
: "text-gray-600 dark:text-gray-300 font-medium"
|
||||
}
|
||||
`}
|
||||
aria-disabled={isMonitorBlocked}
|
||||
@@ -446,7 +434,7 @@ export default function AdminLayout({
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
||||
className="absolute inset-0 rounded-2xl bg-white/80 dark:bg-white/10 shadow-[0_2px_8px_rgba(0,0,0,0.04)] border border-white/50 dark:border-white/5"
|
||||
layoutId="sidebar-active"
|
||||
transition={{
|
||||
type: "spring",
|
||||
@@ -457,18 +445,18 @@ export default function AdminLayout({
|
||||
)}
|
||||
{!isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
|
||||
className="absolute inset-0 rounded-2xl bg-white/40 dark:bg-white/5 opacity-0"
|
||||
transition={{ duration: 0.15 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-3"}`}
|
||||
>
|
||||
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||
<span className="relative z-10 whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -480,7 +468,7 @@ export default function AdminLayout({
|
||||
</nav>
|
||||
|
||||
{/* 底部版权信息和折叠按钮 */}
|
||||
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="px-5 py-6 mt-auto flex-shrink-0 flex flex-col gap-4 overflow-hidden whitespace-nowrap box-border">
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
|
||||
>
|
||||
@@ -496,13 +484,12 @@ export default function AdminLayout({
|
||||
{!isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full mx-auto"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={toggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// 向右扩展的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
@@ -517,7 +504,6 @@ export default function AdminLayout({
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// 向左收起的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
@@ -539,106 +525,34 @@ export default function AdminLayout({
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div
|
||||
className={`flex flex-col flex-1 ${isMobile ? "min-h-0" : "h-full overflow-hidden"}`}
|
||||
className={`flex flex-col flex-1 ${isMobile ? "min-h-0" : "h-full overflow-hidden"} relative`}
|
||||
>
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="lg:hidden"
|
||||
variant="light"
|
||||
onPress={toggleMobileMenu}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
className="text-sm font-medium text-foreground"
|
||||
variant="light"
|
||||
>
|
||||
{username}
|
||||
<svg
|
||||
className="w-4 h-4 ml-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="用户菜单">
|
||||
<DropdownItem
|
||||
key="change-password"
|
||||
startContent={
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
onPress={onOpen}
|
||||
>
|
||||
修改密码
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="logout"
|
||||
className="text-danger"
|
||||
color="danger"
|
||||
startContent={
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
退出登录
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
{/* 移动端菜单按钮 (替代Header) */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="absolute top-4 left-4 z-40 bg-white/60 dark:bg-zinc-900/60 backdrop-blur-md shadow-sm border border-white/80 dark:border-white/10"
|
||||
variant="flat"
|
||||
onPress={toggleMobileMenu}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 主内容 */}
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto [scrollbar-width:none]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
@@ -662,7 +576,7 @@ export default function AdminLayout({
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
scrollBehavior="inside"
|
||||
size="2xl"
|
||||
onOpenChange={() => {
|
||||
onOpenChange();
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function DefaultLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-screen bg-white dark:bg-black">
|
||||
<div className="relative flex flex-col min-h-screen bg-mesh-gradient">
|
||||
<Navbar />
|
||||
<main className="container mx-auto max-w-7xl px-4 sm:px-6 flex-grow pt-4 sm:pt-16">
|
||||
{children}
|
||||
|
||||
@@ -21,9 +21,9 @@ export default function H5SimpleLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
|
||||
<div className="flex flex-col min-h-screen bg-mesh-gradient">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<header className="bg-white/60 dark:bg-zinc-900/60 backdrop-blur-xl shadow-sm border-b border-white/80 dark:border-white/10 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button isIconOnly size="sm" variant="light" onPress={handleBack}>
|
||||
<BackIcon className="w-5 h-5" />
|
||||
@@ -38,7 +38,7 @@ export default function H5SimpleLayout({
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black pb-0">{children}</main>
|
||||
<main className="flex-1 pb-0">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,9 +166,9 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
|
||||
<div className="flex flex-col min-h-screen bg-mesh-gradient">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<header className="bg-white/60 dark:bg-zinc-900/60 backdrop-blur-xl shadow-sm border-b border-white/80 dark:border-white/10 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
@@ -180,13 +180,13 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
|
||||
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
<nav className="bg-white/70 dark:bg-zinc-900/70 backdrop-blur-2xl border-t border-white/80 dark:border-white/10 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
const isMonitor = item.path === "/monitor";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import {
|
||||
updateConfigs,
|
||||
activateLicense,
|
||||
exportBackup,
|
||||
importBackup,
|
||||
getAnnouncement,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
import { ThemeSettings } from "@/components/theme-settings";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||
import {
|
||||
@@ -62,7 +65,7 @@ interface ConfigItem {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
type: "input" | "switch" | "select";
|
||||
type: "input" | "switch" | "select" | "bg_image";
|
||||
options?: { label: string; value: string; description?: string }[];
|
||||
dependsOn?: string; // 依赖的配置项key
|
||||
dependsValue?: string; // 依赖的配置项值
|
||||
@@ -83,6 +86,12 @@ const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
|
||||
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
key: "app_bg_image",
|
||||
label: "自定义背景",
|
||||
description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
|
||||
type: "bg_image",
|
||||
},
|
||||
{
|
||||
key: "ip",
|
||||
label: "面板后端地址",
|
||||
@@ -117,12 +126,24 @@ 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: "启用验证码",
|
||||
@@ -147,6 +168,21 @@ 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 = [
|
||||
@@ -174,10 +210,13 @@ 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> = {};
|
||||
|
||||
@@ -215,8 +254,14 @@ 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);
|
||||
const bgImageFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [bgImageUploading, setBgImageUploading] = useState(false);
|
||||
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||
content: "",
|
||||
@@ -340,6 +385,29 @@ 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 };
|
||||
|
||||
@@ -406,6 +474,15 @@ 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);
|
||||
}
|
||||
@@ -418,6 +495,11 @@ export default function ConfigPage() {
|
||||
|
||||
// 检查配置项是否应该显示(依赖检查)
|
||||
const shouldShowItem = (item: ConfigItem): boolean => {
|
||||
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示
|
||||
if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!item.dependsOn || !item.dependsValue) {
|
||||
return true;
|
||||
}
|
||||
@@ -473,6 +555,118 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("只能上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setBgImageUploading(true);
|
||||
try {
|
||||
const compressedImage = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
const MAX_WIDTH = 1920;
|
||||
const MAX_HEIGHT = 1080;
|
||||
|
||||
if (width > height) {
|
||||
if (width > MAX_WIDTH) {
|
||||
height = Math.round((height * MAX_WIDTH) / width);
|
||||
width = MAX_WIDTH;
|
||||
}
|
||||
} else {
|
||||
if (height > MAX_HEIGHT) {
|
||||
width = Math.round((width * MAX_HEIGHT) / height);
|
||||
height = MAX_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Canvas context is null"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Output as webp for better compression
|
||||
const dataUrl = canvas.toDataURL("image/webp", 0.8);
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.onerror = () => reject(new Error("图片加载失败"));
|
||||
img.src = event.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => reject(new Error("文件读取失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
handleConfigChange("app_bg_image", compressedImage);
|
||||
toast.success("自定义背景上传成功");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "图片处理失败");
|
||||
} finally {
|
||||
setBgImageUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderBgImageUploader = () => {
|
||||
const bgImage = configs["app_bg_image"] || "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={bgImageFileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleBgImageUpload}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() => bgImageFileInputRef.current?.click()}
|
||||
isLoading={bgImageUploading}
|
||||
>
|
||||
选择图片
|
||||
</Button>
|
||||
{bgImage && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange("app_bg_image", "")}
|
||||
isDisabled={bgImageUploading}
|
||||
>
|
||||
清除背景
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{bgImage && (
|
||||
<div className="relative rounded-xl overflow-hidden border border-divider">
|
||||
<img
|
||||
src={bgImage}
|
||||
alt="背景预览"
|
||||
className="w-full max-h-48 object-cover opacity-80"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
|
||||
<span className="text-white text-sm font-medium">预览效果</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBrandPreview = (key: BrandPreviewKey) => {
|
||||
const previewUrl = (configs[key] || "").trim();
|
||||
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
|
||||
@@ -556,6 +750,7 @@ 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
|
||||
@@ -569,6 +764,7 @@ export default function ConfigPage() {
|
||||
ref={getBrandInputRef(key)}
|
||||
accept={BRAND_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
disabled={uploading || isCommercialDisabled}
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
void handleBrandFileChange(key, event);
|
||||
@@ -579,6 +775,7 @@ export default function ConfigPage() {
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={uploading}
|
||||
isDisabled={isCommercialDisabled}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => triggerBrandFilePicker(key)}
|
||||
@@ -619,8 +816,12 @@ 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 "bg_image":
|
||||
return renderBgImageUploader();
|
||||
|
||||
case "input":
|
||||
if (isBrandPreviewKey(item.key)) {
|
||||
return renderBrandAssetUploader(item.key, isChanged);
|
||||
@@ -639,6 +840,8 @@ export default function ConfigPage() {
|
||||
value={configs[item.key] || ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||
isDisabled={isCommercialDisabled}
|
||||
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -648,12 +851,12 @@ export default function ConfigPage() {
|
||||
classNames={{
|
||||
wrapper: isChanged ? "border-warning-300" : "",
|
||||
}}
|
||||
color="primary"
|
||||
isSelected={configs[item.key] === "true"}
|
||||
size="md"
|
||||
size="sm"
|
||||
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" ? "已启用" : "已禁用"}
|
||||
@@ -880,6 +1083,69 @@ 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 mb-6">
|
||||
<Input
|
||||
label="授权激活码"
|
||||
placeholder="请输入 FLVX- 开头的商业授权码"
|
||||
value={licenseKeyInput}
|
||||
variant="bordered"
|
||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||
isDisabled={configs.is_commercial === "true"}
|
||||
description={configs.is_commercial === "true" ? (configs.license_expiry && configs.license_expiry !== "never" ? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})` : "已激活商业版授权 (永久有效)") : "需商业授权才能修改站名、图标并隐藏页脚品牌"}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
className="mb-6"
|
||||
isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
|
||||
isLoading={activatingLicense}
|
||||
onPress={handleActivateLicense}
|
||||
>
|
||||
{configs.is_commercial === "true" ? "已授权" : "激活授权"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{configs.is_commercial === "true" && (
|
||||
<div className="space-y-6">
|
||||
<Divider className="my-2" />
|
||||
<h3 className="text-md font-medium text-default-700">白标与品牌设置</h3>
|
||||
{CONFIG_ITEMS.filter(item => ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)).map((item, index) => (
|
||||
<div key={item.key}>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{item.label}
|
||||
</label>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
{renderConfigItem(item)}
|
||||
</div>
|
||||
</div>
|
||||
{index < 3 && <Divider className="mt-6" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex items-center w-full">
|
||||
@@ -979,6 +1245,11 @@ export default function ConfigPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 主题设置 */}
|
||||
<div className="mt-6">
|
||||
<ThemeSettings />
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
|
||||
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
@@ -1198,6 +1469,30 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@/shadcn-bridge/heroui/dropdown";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -12,8 +19,11 @@ import {
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
|
||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||
import { getSessionName } from "@/utils/session";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import {
|
||||
formatNodeRenewalTime,
|
||||
getNodeRenewalCycleLabel,
|
||||
@@ -34,6 +44,15 @@ interface AddressItem {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const username = getSessionName() || "User";
|
||||
|
||||
const handleLogout = () => {
|
||||
safeLogout();
|
||||
toast.success("已退出登录");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const {
|
||||
loading,
|
||||
userInfo,
|
||||
@@ -43,6 +62,9 @@ export default function DashboardPage() {
|
||||
nodeExpiryReminders,
|
||||
isAdmin,
|
||||
announcement,
|
||||
isAnnouncementModalOpen,
|
||||
setIsAnnouncementModalOpen,
|
||||
dismissAnnouncementModal,
|
||||
} = useDashboardData();
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
@@ -626,7 +648,57 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{/* 顶部个人状态和下拉菜单 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">Good morning, {username}</h1>
|
||||
<p className="text-sm text-default-500 mt-1">Here's what's happening with your network today.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
|
||||
isIconOnly
|
||||
variant="solid"
|
||||
>
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="用户菜单" className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
|
||||
<DropdownItem
|
||||
key="profile"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
}
|
||||
onPress={() => navigate("/profile")}
|
||||
>
|
||||
个人资料
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="logout"
|
||||
className="text-danger"
|
||||
color="danger"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
|
||||
}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
退出登录
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} />}
|
||||
{announcement && (
|
||||
<AnnouncementModal
|
||||
announcement={announcement}
|
||||
isOpen={isAnnouncementModalOpen}
|
||||
onClose={() => setIsAnnouncementModalOpen(false)}
|
||||
onDontShowAgain={dismissAnnouncementModal}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<MetricCard
|
||||
icon={
|
||||
@@ -811,7 +883,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<Card className="mb-6 lg:mb-8">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
@@ -933,7 +1005,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* 规则配置 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
@@ -1004,7 +1076,7 @@ export default function DashboardPage() {
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30" : ""}`}
|
||||
className={`block px-2 py-1 bg-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
|
||||
disabled={!hasMultipleIps(forward.inIp)}
|
||||
title={formatInAddress(
|
||||
forward.inIp,
|
||||
@@ -1025,7 +1097,7 @@ export default function DashboardPage() {
|
||||
↓
|
||||
</div>
|
||||
<button
|
||||
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30" : ""}`}
|
||||
className={`block px-2 py-1 bg-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
|
||||
disabled={
|
||||
!hasMultipleRemoteAddresses(
|
||||
forward.remoteAddr,
|
||||
@@ -1091,7 +1163,7 @@ export default function DashboardPage() {
|
||||
backdrop="blur"
|
||||
isOpen={addressModalOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
scrollBehavior="inside"
|
||||
size="2xl"
|
||||
onClose={() => setAddressModalOpen(false)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AnnouncementData } from "@/api";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
announcement: AnnouncementData;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export const AnnouncementModal = ({
|
||||
announcement,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDontShowAgain,
|
||||
}: AnnouncementModalProps) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={onDontShowAgain}>
|
||||
不再提示
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
@@ -28,7 +27,7 @@ export const FlowChartCard = ({
|
||||
formatFlow,
|
||||
}: FlowChartCardProps) => {
|
||||
return (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<Card className="mb-6 lg:mb-8">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
@@ -51,17 +50,16 @@ export const FlowChartCard = ({
|
||||
) : (
|
||||
<div className="h-64 lg:h-80 w-full">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
|
||||
<LineChart data={chartData} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
|
||||
<XAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={{ fill: "currentColor", opacity: 0.5, fontSize: 12 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tick={{ fill: "currentColor", opacity: 0.5, fontSize: 12 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v = typeof value === "number" ? value : Number(value);
|
||||
|
||||
@@ -97,27 +95,20 @@ export const FlowChartCard = ({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<div className="bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-white/50 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-4">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
|
||||
<p className="text-primary font-semibold mt-1">{`流量: ${formatFlow(flowValue)}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
|
||||
/>
|
||||
<Line
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: "#8b5cf6",
|
||||
strokeWidth: 2,
|
||||
fill: "#fff",
|
||||
}}
|
||||
dataKey="flow"
|
||||
dot={false}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
stroke="#007aff"
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user