Compare commits

...

83 Commits

Author SHA1 Message Date
sagit 4e088afb29 feat: show license expiry date when commercial license is activated (#407)
## Summary
- Extracted the `expiry` attribute from Keygen's `ValidateResponse`.
- Updated `licenseActivate` and `validateLicenseJob` to store
`license_expiry` in the database.
- Read `license_expiry` into the frontend `siteConfig` map.
- Updated the description in the commercial license input to dynamically
show the expiry date: '已激活商业版授权 (有效期至: YYYY-MM-DD)' or '已激活商业版授权 (永久有效)'
if it doesn't expire.
2026-04-03 17:39:07 +08:00
sagitchu 1b3ae44940 feat: show license expiry date when commercial license is activated 2026-04-03 17:37:05 +08:00
sagit a91abbfebd feat(ui): move commercial brand settings to license card (#406)
## Summary
- Conditionally render brand settings (`app_name`, `app_logo`,
`app_favicon`, `hide_footer_brand`) inside the Commercial License card.
- Hide them from the main Basic Settings list.
- Only show these options if the user has successfully activated a
commercial license.
2026-04-03 17:31:00 +08:00
sagitchu 513591fe67 feat(ui): conditionally render brand settings inside commercial license card 2026-04-03 17:27:48 +08:00
sagit 9412d24c02 feat: show tunnel traffic ratio in forward list (#405)
## Summary
- Display the tunnel's traffic ratio in the forward (rules) list UI to
save administrators an extra click to the tunnels page.
- Resolves #forward-show-tunnel-ratio

## Changes
- Updated the table row in `forward.tsx` to show the traffic multiplier.
- It will only display when the ratio is not `1x` to prevent UI clutter.
- The backend `forwardList` API was already returning `trafficRatio`
from a `LEFT JOIN tunnel`, so no backend modifications were required.
2026-04-03 16:34:04 +08:00
sagit ab3ca019d2 fix: prevent idle transaction timeout in postgresql during tunnel update (#404)
## Summary
- Moved `tx := h.repo.BeginTx()` below `applyFederationRuntime` in
`tunnelUpdate`
- This prevents the database transaction from remaining idle for an
extended period of time while making HTTP network requests to federation
nodes, which can trigger the `idle_in_transaction_session_timeout` or
cause connection pool exhaustion in PostgreSQL.

## Test Plan
- [x] Backend tests passed (`go test ./...`)
- [ ] Verify tunnel updates no longer timeout with PostgreSQL
2026-04-03 16:30:35 +08:00
sagitchu b892b2640e feat(ui): display tunnel traffic ratio in forward list 2026-04-03 16:18:01 +08:00
sagitchu 3da9b14bfe fix(backend): prevent idle transaction timeout in postgresql during tunnel update 2026-04-03 15:52:48 +08:00
sagit 49ab2915ee feat: commercial white-label support (#403)
* fix: increase updateTunnel timeout to 120s

Editing tunnel entry nodes triggers forward sync to all entry nodes.
If nodes are offline or many forwards exist, the sync can exceed
the default 30s timeout. Match the timeout used by other heavy
operations like batchDeleteTunnels.

* docs: add commercial white-label design spec

* docs: add commercial white-label implementation plan

* feat: add license activation endpoint and authorization check for commercial config keys

* feat: add frontend api and update site config state for license

* feat: conditionally hide flvx footer brand

* feat: ui settings for commercial white-label and license activation

* fix: add missing licenseActivateRequest and fix GetConfig in handler.go

* docs: add keygen.sh license integration design spec

* docs: add keygen.sh integration implementation plan

* feat: add machine fingerprint generation

* feat: add keygen.sh api client

* feat: integrate keygen into license activation endpoint

* feat: add periodic license validation job

* fix: remove accidentally leaked dash kernel test codes that caused compilation failures

* fix: correct keygen validation scope and binding logic

* feat: hardcode Keygen.sh account ID

* fix: relax strict validation matching after successful machine activation
2026-04-03 07:23:22 +00:00
sagit becf87118f fix(backend): randomize new tunnel relay ports safely (#402)
## Summary
- randomize auto-assigned ports only for newly created tunnel relay and
exit nodes
- keep tunnel updates stable while tightening batch forward
tunnel-switch validation
- update contract coverage for deferred offline runtime handling and
missing entry-node fixtures

## Test Plan
- go test ./... -count=1
2026-04-01 20:19:10 +08:00
sagitchu 608fbf74de fix(backend): randomize new tunnel relay ports safely 2026-04-01 20:16:31 +08:00
sagit 8b9cdef0e4 feat: add configurable GitHub proxy settings (#401)
* docs: add GitHub proxy config design spec

* docs: add GitHub proxy config implementation plan

* feat(backend): use configurable github proxy for node upgrades

* feat(backend): use configurable github proxy for node install command

* feat(frontend): add github proxy config settings

* feat(script): support configurable github proxy in installer flows

Honor custom GitHub mirror settings across interactive and env-driven installer/update paths so script downloads match the panel configuration. Add shell regressions to lock down proxy prompting, URL recomputation, and non-interactive fallback behavior.
2026-04-01 15:59:44 +08:00
sagit 3c10727e08 feat(config): add floating save button (FAB) for issue #266 (#400)
* docs: add floating save button design spec for issue #266

* docs: add implementation plan for floating save button

* feat(config): add floating save button (FAB) for issue #266
2026-04-01 01:50:32 +00:00
qimaoww 841d43344a fix(backend): 修复转发监听 IP 为 IPv6 时报missing port in address (#397)
* fix(backend): handle IPv6 forward bind IP ports

* test(handler): add IPv6 bindIP test cases for forward service config

---------

Co-authored-by: sagit <36596628+Sagit-chu@users.noreply.github.com>
Co-authored-by: sagitchu <sagitchu@gmail.com>
2026-03-31 03:24:56 +00:00
sagit 5efe790937 fix: 实现用户端口数量限制验证 (#399)
- 在 ensureUserTunnelForwardAllowed 中添加 User.Num 限制验证
- 在 ensureUserTunnelForwardAllowed 中添加 UserTunnel.Num 限制验证
- 新增 CountActiveForwardsByUser 和 CountActiveForwardsByUserTunnel 函数
- 添加转发数量限制的契约测试

Closes #390
2026-03-31 02:52:49 +00:00
sagit eec6cb4298 fix(agent): add port cleanup before resuming paused services (#398)
When user traffic quota is exceeded, services are paused with
ForceClosePortConnections to kill active connections. However,
when admin resets quota and resumes services, the resume logic
was missing this cleanup, causing "address already in use" errors.

Changes:
- Add ForceClosePortConnections call in resumeServices (socket & api)
- Add ForceClosePortConnections call in resumeService (api single)
- Increase wait time from 100ms to 500ms for port release

Fixes #387
2026-03-31 10:28:28 +08:00
Misaka Master 352fc82907 fix(backend): include interfaceName in ListNodes API response (#395)
- Add missing interfaceName field to node list API response map
- Fixes bug where interface name value disappears after page refresh
- Field is correctly saved to DB but was not returned in API response

Co-authored-by: Alex-WU-Gen-9-png <github@enomria0785.eu.org>
2026-03-29 20:59:52 +08:00
sagit 87722e461c feat(monitor): hop-by-hop latency metrics for forwarding chain (#394) 2026-03-29 18:59:06 +08:00
sagit 701b4011cb feat: move tunnel quality monitor toggle from /settings to /config (#393)
将实时隧道质量检测开关从 /settings 移到 /config 页面,统一管理全局配置项。
2026-03-29 15:48:45 +08:00
sagitchu d128d2f657 feat: move tunnel quality monitor toggle from /settings to /config 2026-03-29 15:47:10 +08:00
sagit 400a40fe80 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#392)
## 修复内容

修复 #342

### 问题
当节点离线时,用户无法编辑隧道配置(包括更换节点),也无法修改相关的转发规则。

### 变更

**Backend:**
- `prepareTunnelCreateState`: 更新隧道时,允许已关联的离线节点保留(用户可能在移除它们),仅拒绝新增的离线节点
- `syncForwardServicesWithWarnings`: 离线节点跳过下发并返回警告,不再硬性失败
- `applyTunnelRuntime`: 所有节点类型(入口/转发链/出口)均支持离线错误延迟处理
- 新增 `isNodeOfflineOrTimeoutError` 辅助函数

**Frontend:**
- `validateTunnelForm`: 新增 `isEdit` 参数,编辑模式下跳过离线节点验证
- `tunnel.tsx`: 传递 `isEdit` 标志到表单验证
2026-03-28 19:31:54 +08:00
sagitchu 103290ed35 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#342)
- prepareTunnelCreateState: 更新隧道时允许已关联的离线节点,仅拒绝新增的离线节点
- syncForwardServicesWithWarnings: 离线节点跳过下发并返回警告,不再硬性失败
- applyTunnelRuntime: 所有节点类型均支持离线错误延迟处理
- 前端 validateTunnelForm: 编辑模式下跳过离线节点验证

Closes #342
2026-03-28 19:30:30 +08:00
sagitchu 363e714603 fix: 支持跨版本隧道链路 (v6入v4出 / v4入v6出)
问题:selectTunnelDialHost 只检查同版本兼容 (v4->v4, v6->v6),
导致 v6-only 入口节点连接 v4-only 出口节点时报错:
"节点链路不兼容"

修复:在 default 分支增加跨版本支持:
- fromV6 && toV4 → 返回出口 v4 地址
- fromV4 && toV6 → 返回出口 v6 地址

更新测试用例以反映新行为
2026-03-28 10:35:47 +08:00
sagit afd1258fcd fix(monitor): add tunnel quality detection toggle (#386)
## Summary
- add a global settings toggle to enable or disable real-time tunnel
quality detection
- stop frontend tunnel quality polling and related status UI when the
toggle is off
- gate the backend tunnel quality prober so disabling the setting also
stops server-side probing

## Test plan
- [x] cd go-backend && go test ./...
- [x] cd vite-frontend && npm run build

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-26 20:10:31 +08:00
sagitchu e69082a596 fix(monitor): add tunnel quality detection toggle
Allow admins to disable real-time tunnel quality probing from settings so the monitor UI and backend probe loop stop together.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:08:40 +08:00
sagit d30363d164 docs: update agent context to 2.1.9-rc10 (#385) 2026-03-25 20:27:40 +08:00
sagitchu 8e1a87bf5a docs: update agent context to 2.1.9-rc10 2026-03-25 20:26:09 +08:00
sagit 6180b5a198 fix(backend): clean up forwards when revoking tunnel permissions (#384) 2026-03-25 20:25:49 +08:00
sagitchu 61d95ab5d5 fix(backend): clean up forwards when revoking tunnel permissions 2026-03-25 20:24:27 +08:00
sagit c27be19915 fix: bracket IPv6 forward entry addresses (#383)
Return standard [host]:port values from forward list responses so IPv6 rule entries can be copied directly from the UI.
2026-03-24 22:16:55 +08:00
sagit f62a35c3f9 fix: resolve PostgreSQL type encoding issues for node metrics (#381)
Fixed OID 25 (text) inference failing to encode int64 by embedding the
node ID format string directly. Replaced CAST(x AS INTEGER) with CAST(x
AS BIGINT) to prevent 32-bit overflow on Unix milliseconds timestamps.
2026-03-24 10:46:29 +08:00
sagitchu 2a1caf32c4 fix(monitor): resolve PostgreSQL type encoding issues for node metrics 2026-03-24 10:45:17 +08:00
sagit fdcc30a493 release: 2.1.9-rc8 (#380)
Release 2.1.9-rc8
2026-03-24 09:46:39 +08:00
sagitchu efaffb0475 chore: release 2.1.9-rc8 2026-03-24 09:45:21 +08:00
sagitchu 4954526cbc chore: finalize release plan 064 2026-03-24 07:23:23 +08:00
sagit 9d50071915 chore: release 2.1.9-rc7 (#379)
Bump version to 2.1.9-rc7 in AGENTS.md
2026-03-24 07:22:52 +08:00
sagitchu ceceee6ebd chore: bump version to 2.1.9-rc7 2026-03-24 07:21:20 +08:00
sagitchu 11051f5517 chore: finalize release plan 063 2026-03-24 07:18:56 +08:00
sagit ff2c7c4959 chore: release 2.1.9-rc6 (#378)
Bump version to 2.1.9-rc6 in AGENTS.md
2026-03-24 07:18:26 +08:00
sagitchu 6364b96935 chore: bump version to 2.1.9-rc6 2026-03-24 07:16:01 +08:00
sagit 409f0a232a fix: bump npm package version to 2.1.9-rc5 (#376)
Bumps version in package.json and AGENTS.md
2026-03-24 00:58:34 +08:00
sagitchu f79994e0e0 fix: bump npm package version to 2.1.9-rc5 to fix CI error 2026-03-24 00:56:08 +08:00
sagit 9fdb16d035 chore: bump version to 2.1.9-rc4 (#375)
Bump version tag in AGENTS.md
2026-03-23 23:01:58 +08:00
sagitchu 53b632a6f7 chore: bump version to 2.1.9-rc4 2026-03-23 22:59:47 +08:00
sagit bf7b2a0740 fix: re-assign tunnel ports automatically for out-of-range entries (#373) (#374)
Automatically re-assign ports from available ranges instead of rejecting
tunnel modification when adding new entry nodes.
2026-03-23 22:59:13 +08:00
sagitchu 8475bc27bb fix: re-assign port automatically if out of range for new tunnel entries (fixes #373) 2026-03-23 22:57:49 +08:00
sagit 5d01572eff release: 2.1.9-rc3 (#371)
Sync all changes and fix service monitor stale detection
2026-03-22 19:06:43 +08:00
sagitchu a353faaa71 chore: release 2.1.9-rc3 (fix service monitor stale detection) 2026-03-22 19:05:31 +08:00
sagit e7b25004ba Update monitor rendering (#370)
Merge all changes into main for RC2 release.
2026-03-22 17:56:14 +08:00
sagitchu 3826cb02c0 chore: update monitor rendering and release rc2 2026-03-22 17:55:10 +08:00
sagitchu a1fee8e432 Merge remote-tracking branch 'origin/main' into fix-agents-v14-final 2026-03-22 13:33:58 +08:00
sagitchu aafdb78482 feat: node logo by distro types and official icons 2026-03-22 13:33:37 +08:00
sagit 16b545d8cd chore: update AGENTS.md for release 2.1.9-beta14 (#368) 2026-03-22 05:05:50 +00:00
sagitchu 45065178b8 chore: update AGENTS.md for release 2.1.9-beta14 2026-03-22 13:04:43 +08:00
sagit 8ebde9dca9 feat: node OS logo and UI fixes (#367)
* chore: update AGENTS.md with next release info

* feat: node OS logo, UI rate fix, and monitor trend updates
2026-03-22 05:03:04 +00:00
sagit 0a1ec60750 fix: qualify tunnel_metric columns (#366)
fix ambiguous column reference in tunnel_metric upserts
2026-03-21 19:42:00 +08:00
sagitchu 9ec35d2f2f merge main into sync-agents-v12 2026-03-21 19:40:44 +08:00
sagitchu c914040b7d fix(repo): qualify tunnel_metric columns to avoid ambiguity in ON CONFLICT 2026-03-21 19:40:16 +08:00
sagit 822362c44c Update AGENTS.md for release v2.1.9-beta12 (#365)
Updating commit SHA and tag in AGENTS.md for the latest release.
2026-03-21 19:24:51 +08:00
sagitchu 80f5935b76 chore: update AGENTS.md for release v2.1.9-beta12 2026-03-21 19:23:20 +08:00
sagit 433c8aab13 Tunnel metrics ingestion logging improvements (#364)
Added debug logging for better tunnel metrics monitoring.
2026-03-21 19:22:32 +08:00
sagitchu 949dfcd42d Merge branch 'main' into sync-all-changes
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-03-21 19:21:26 +08:00
sagitchu 1580e4ee10 chore: [monitoring] improve tunnel metric ingestion logging 2026-03-21 19:20:23 +08:00
sagit 32e4f0f514 feat: sync all changes (#363)
Automated changes to monitoring and system info
2026-03-21 18:41:42 +08:00
sagitchu ca3a643ef7 feat: optimize node monitoring and system info 2026-03-21 18:40:28 +08:00
sagitchu ce2b234843 chore: update AGENTS.md for release v2.1.9-beta10 2026-03-21 17:31:08 +08:00
sagit ac3506847c feat: implement tunnel quality polling and service monitor tuning (#362)
Implement 1s test, 30s report pattern across all monitoring subsystems.
2026-03-21 17:30:07 +08:00
sagitchu 6e3d604618 feat: implement tunnel quality polling and service monitor tuning to 1s/30s intervals 2026-03-21 17:28:52 +08:00
sagit 4417ece7cd fix: renewal reminder styling and position (#361)
Fixes the styling of the renewal reminder for nodes that have been
dismissed.
2026-03-21 10:04:02 +08:00
sagitchu bab4371ba7 fix: renewal reminder styling and position 2026-03-21 10:02:52 +08:00
sagit 960c97cee4 release: v2.1.9-beta9 (#360)
Theme persistence fix and version bump
2026-03-20 23:39:42 +08:00
sagitchu 9d05d75fd6 docs: update AGENTS.md for release v2.1.9-beta9 2026-03-20 23:37:47 +08:00
sagitchu c137bdcc63 fix: theme persistence and update version to 2.1.9-beta9 2026-03-20 23:37:35 +08:00
sagit b7065f6e99 feat: implement theme selection and system integration (#359)
Integrated theme selection into settings page and unified theme
provider.
2026-03-20 22:49:06 +08:00
sagitchu bd4e1f66cb feat: implement theme selection and system integration 2026-03-20 22:47:49 +08:00
sagitchu 9f0670f4d0 docs: complete plan 058 2026-03-20 22:20:49 +08:00
sagit 32e338d295 fix: include missing field in repository (#358)
Included missing field in repository.
2026-03-20 22:20:15 +08:00
sagitchu f6a753baa3 fix: include missing field in repository 2026-03-20 22:18:45 +08:00
sagit 322a10bb9d chore: release 2.1.9-beta7 (#357)
Sync current changes to 2.1.9-beta7.
2026-03-20 22:18:17 +08:00
sagitchu 27c13d6c47 chore: release 2.1.9-beta7 2026-03-20 22:15:51 +08:00
sagit f45f96063a fix: hide remote nodes from monitor view (#356)
Filter out nodes with is_remote=1 from the monitor API endpoint, as per
user requirement.
2026-03-20 18:42:58 +08:00
sagitchu 1780be73b9 fix(monitor): hide remote nodes from monitor view 2026-03-20 18:41:31 +08:00
sagit addf8e2089 feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355) 2026-03-20 14:17:16 +08:00
101 changed files with 7454 additions and 845 deletions
+1
View File
@@ -67,6 +67,7 @@ go-gost/ss/
.entire/
bin/
tmp/
.worktrees/
*.swp
*.bak
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -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,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. 测试激活、吊销、过期、断网等各种场景。
+5
View File
@@ -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
View File
@@ -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
)
+53 -11
View File
@@ -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)
}
}
}
}
}()
+4 -1
View File
@@ -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
}
+138 -1
View File
@@ -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
@@ -46,6 +48,8 @@ type Handler struct {
qualityProber *tunnelQualityProber
}
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -66,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"`
@@ -135,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)
@@ -786,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("请求失败"))
@@ -802,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)
@@ -809,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()))
@@ -841,6 +951,12 @@ 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()))
@@ -886,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("请求失败"))
+60 -3
View File
@@ -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(6)
h.jobsWG.Add(7)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -27,6 +29,59 @@ func (h *Handler) StartBackgroundJobs() {
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
go h.runValidateLicenseJob(ctx)
}
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
defer h.jobsWG.Done()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.validateLicenseJob()
}
}
}
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
// Network error or timeout. Grace period by not revoking immediately here.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
} 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() {
@@ -66,9 +121,11 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h.qualityProber != nil {
h.qualityProber.Start(ctx)
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
return
}
h.qualityProber.Start(ctx)
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
@@ -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,
})
}
@@ -243,6 +245,7 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
ChainDetails: q.ChainDetails,
})
}
response.WriteJSON(w, response.OK(snapshots))
@@ -695,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 {
@@ -720,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()))
+237 -32
View File
@@ -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))
}
}
@@ -2,21 +2,35 @@ package handler
import (
"context"
"encoding/json"
"log"
"sync"
"sync/atomic"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 10 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
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"`
@@ -27,6 +41,10 @@ type tunnelQualitySnapshot struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
ChainDetails string `json:"chainDetails,omitempty"`
// internal fields for db reporting
lastDBWrite int64 `json:"-"`
}
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
@@ -38,15 +56,13 @@ type tunnelQualityProber struct {
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 {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
@@ -60,6 +76,10 @@ func (p *tunnelQualityProber) Start(ctx context.Context) {
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
if p == nil || p.cancel == nil {
return
}
p.cancel()
}
@@ -100,8 +120,20 @@ func (p *tunnelQualityProber) loop() {
}
}
func (p *tunnelQualityProber) isEnabled() bool {
if p == nil || p.handler == nil {
return true
}
return p.handler.isTunnelQualityMonitoringEnabled()
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
if !p.isEnabled() {
return
}
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
@@ -120,6 +152,16 @@ func (p *tunnelQualityProber) maybePrune() {
}
func (p *tunnelQualityProber) probeAll() {
if !p.isEnabled() {
return
}
// Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return
}
defer atomic.StoreInt32(&p.probing, 0)
h := p.handler
if h == nil || h.repo == nil {
return
@@ -136,7 +178,7 @@ func (p *tunnelQualityProber) probeAll() {
// Probe tunnels concurrently with a worker limit
// (mirrors health.Checker worker pool pattern)
const maxWorkers = 4
const maxWorkers = 20
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
@@ -186,7 +228,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
@@ -212,28 +254,85 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
// Entry → Exit
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
if nodeErr == nil && targetNode != nil {
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
if resolveErr == nil {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
if err == nil {
snap.EntryToExitLatency = lat
snap.EntryToExitLoss = loss
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
probeOK = false
}
} else {
snap.ErrorMessage = resolveErr.Error()
probeOK = false
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.ErrorMessage = "出口节点不可用"
probeOK = false
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
}
if len(hops) > 0 {
if b, err := json.Marshal(hops); err == nil {
snap.ChainDetails = string(b)
}
}
}
@@ -303,8 +402,28 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
}
// 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 {
@@ -325,6 +444,7 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
Success: successInt,
ErrorMessage: snap.ErrorMessage,
Timestamp: snap.Timestamp,
ChainDetails: snap.ChainDetails,
}
if err := h.repo.InsertTunnelQuality(q); err != nil {
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
+40 -17
View File
@@ -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"`
+187
View File
@@ -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)
}
+4 -4
View File
@@ -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,
+4 -3
View File
@@ -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"`
@@ -728,6 +728,7 @@ type TunnelQuality struct {
Success int `gorm:"not null;default:1" json:"success"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
ChainDetails string `gorm:"column:chain_details;type:text" json:"chainDetails,omitempty"`
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
+95 -16
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -677,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
@@ -3240,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)
@@ -3257,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
@@ -3319,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
}
@@ -3421,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
@@ -3568,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,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
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -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.
+5
View File
@@ -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.
+11 -1
View File
@@ -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)
+5
View File
@@ -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.
+4 -1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+4 -1
View File
@@ -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.).
+4 -1
View File
@@ -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.
+21
View File
@@ -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
}
+6 -1
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+21
View File
@@ -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. 构建通过 + 全量测试通过
+16
View File
@@ -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.
+13
View File
@@ -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.
+13
View File
@@ -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`.
+59
View File
@@ -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` 模式
+42
View File
@@ -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秒上报"
+15
View File
@@ -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`).
+49
View File
@@ -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
```
+12
View File
@@ -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`.
+12
View File
@@ -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`.
+11
View File
@@ -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.
+25
View File
@@ -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 -1
View File
@@ -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",
+314
View File
@@ -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"
+3 -3
View File
@@ -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.
+20 -5
View File
@@ -85,11 +85,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 +108,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';
+19 -4
View File
@@ -134,7 +134,7 @@ export const getTunnelList = () =>
export const getTunnelById = (id: number) =>
Network.post<TunnelApiItem>("/tunnel/get", { id });
export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data);
Network.post("/tunnel/update", data, { timeout: 120_000 });
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const previewTunnelDelete = (id: number) =>
@@ -251,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);
@@ -427,6 +430,7 @@ export const getNodeMetrics = (
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
{ timeout: 60_000 },
);
};
@@ -486,11 +490,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[]>(
+13
View File
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
inx: number;
name: string;
status: number;
version?: string;
updatedTime: number;
}
@@ -488,6 +489,17 @@ export interface MonitorAccessApiData {
reason?: string;
}
export interface TunnelQualityHopApiItem {
fromNodeId: number;
fromNodeName: string;
toNodeId: number;
toNodeName: string;
latency: number;
loss: number;
targetIp?: string;
targetPort?: number;
}
export interface TunnelQualityApiItem {
tunnelId: number;
entryToExitLatency: number;
@@ -497,4 +509,5 @@ export interface TunnelQualityApiItem {
success: boolean;
errorMessage?: string;
timestamp: number;
chainDetails?: string;
}
File diff suppressed because one or more lines are too long
+20 -43
View File
@@ -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>
);
};
+13 -11
View File
@@ -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>
);
}
+11
View File
@@ -20,6 +20,8 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: "",
app_favicon: "",
is_commercial: false,
hide_footer_brand: false,
};
}
@@ -27,6 +29,8 @@ const getInitialConfig = () => {
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
const cachedAppFavicon =
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
if (cachedAppName) {
return {
@@ -36,6 +40,8 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
}
@@ -46,6 +52,8 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
};
@@ -272,6 +280,9 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
+174 -2
View File
@@ -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 {
@@ -117,12 +120,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 +162,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 +204,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,6 +248,10 @@ 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);
@@ -340,6 +377,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 +466,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 +487,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;
}
@@ -556,6 +630,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 +644,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 +655,7 @@ export default function ConfigPage() {
<Button
color="primary"
isLoading={uploading}
isDisabled={isCommercialDisabled}
size="sm"
variant="flat"
onPress={() => triggerBrandFilePicker(key)}
@@ -619,6 +696,7 @@ export default function ConfigPage() {
const renderConfigItem = (item: ConfigItem) => {
const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key];
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
switch (item.type) {
case "input":
@@ -639,6 +717,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 +728,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 +960,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 +1122,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 +1346,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>
);
}
+5 -3
View File
@@ -987,9 +987,11 @@ const SortableCompactTableRow = ({
<span className="font-medium text-default-700 text-sm">
{forward.tunnelName}
</span>
<span className="text-success font-bold text-[12px] mr-1.5">
‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
{forward.tunnelTrafficRatio !== undefined && normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 && (
<span className="text-success font-bold text-[12px] ml-1.5 border border-success/30 rounded px-1 bg-success/10">
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
)}
</div>
</TableCell>
<TableCell
+21 -14
View File
@@ -262,6 +262,7 @@ export default function LimitPage() {
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
@@ -274,27 +275,32 @@ export default function LimitPage() {
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn align="center">操作</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell className="font-medium text-foreground">{rule.name}</TableCell>
<TableCell>{rule.speed} Mbps</TableCell>
<TableCell>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 justify-center">
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
color="primary"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
@@ -302,7 +308,7 @@ export default function LimitPage() {
编辑
</Button>
<Button
color="danger"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
@@ -315,6 +321,7 @@ export default function LimitPage() {
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
+2
View File
@@ -15,6 +15,7 @@ type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
version?: string;
};
type MonitorTab = "nodes" | "tunnels";
@@ -73,6 +74,7 @@ export default function MonitorPage() {
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
+4 -9
View File
@@ -609,6 +609,8 @@ export default function NodePage() {
if (!metric || typeof metric !== "object") return node;
const incomingUptime = metric.uptime ?? 0;
return {
...node,
connectionStatus: "online",
@@ -620,7 +622,7 @@ export default function NodePage() {
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
downloadSpeed: metric.netInSpeed ?? metric.net_in_speed ?? 0,
uptime: metric.uptime ?? 0,
uptime: incomingUptime || node.systemInfo?.uptime || 0,
diskUsage: metric.diskUsage ?? metric.disk_usage,
load1: metric.load1,
load5: metric.load5,
@@ -1407,13 +1409,6 @@ export default function NodePage() {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1954,7 +1949,7 @@ export default function NodePage() {
{(listeners) => (
<Card
key={node.id}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
data-node-card="true"
>
<CardHeader className="pb-3 md:pb-3">
+261 -225
View File
@@ -6,7 +6,7 @@ import type {
ServiceMonitorLimitsApiData,
} from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
LineChart,
Line,
@@ -27,9 +27,13 @@ import {
Server,
Clock,
ArrowLeft,
ArrowUp,
ArrowDown,
Eye,
} from "lucide-react";
import toast from "react-hot-toast";
import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon";
import {
getNodeMetrics,
@@ -73,7 +77,7 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps {
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
nodeMap: Map<number, { id: number; name: string; connectionStatus: string; version?: string }>;
viewMode?: "list" | "grid";
}
@@ -165,6 +169,8 @@ const getColorByUsage = (usage?: number) => {
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
const isOnline = node.connectionStatus === "online";
const distro = parseDistroFromVersion(node.version);
const distroColor = getDistroColor(distro);
return (
<Card
@@ -181,7 +187,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
<DistroIcon distro={distro} className="w-5 h-5" style={{ color: isOnline ? distroColor : undefined }} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
</div>
@@ -263,10 +269,147 @@ type MetricType =
const METRICS_MAX_ROWS = 5000;
/* ─── Memoized Node Metrics Chart sub-component ─────────────────── */
interface NodeMetricsChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
activeMetricType: MetricType;
onMetricTypeChange: (t: MetricType) => void;
loading: boolean;
error: string | null;
truncated: boolean;
maxRows: number;
data: Array<Record<string, unknown>>;
nodeId: number | null;
onRefresh: (id: number) => void;
}
const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
];
const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
rangeMs, onRangeChange, activeMetricType, onMetricTypeChange,
loading, error, truncated, maxRows, data, nodeId, onRefresh,
}: NodeMetricsChartCardProps) {
const chartConfig = (() => {
switch (activeMetricType) {
case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" };
case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" };
case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" };
case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" };
case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" };
case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" };
}
})();
const yAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`;
case "load": return n.toFixed(1);
case "connections": return String(Math.round(n));
}
};
const tooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`;
case "load": return n.toFixed(2);
case "connections": return String(Math.round(n));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(rangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onRangeChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={loading} size="sm" variant="flat" onPress={() => nodeId && onRefresh(nodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{METRIC_TYPE_BUTTONS.map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => onMetricTypeChange(item.key)}
>
{item.label}
</Button>
))}
</div>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={yAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={tooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{truncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
);
});
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
checkerScanIntervalSec: 30,
minIntervalSec: 30,
defaultIntervalSec: 60,
checkerScanIntervalSec: 1,
minIntervalSec: 1,
defaultIntervalSec: 1,
minTimeoutSec: 1,
defaultTimeoutSec: 5,
maxTimeoutSec: 60,
@@ -314,7 +457,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
});
const [submitLoading, setSubmitLoading] = useState(false);
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50);
const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000);
const [accessDenied, setAccessDenied] = useState<string | null>(null);
const [resultsModalOpen, setResultsModalOpen] = useState(false);
@@ -386,27 +529,31 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const metric = raw as Record<string, unknown>;
const receivedAt = Date.now();
const normalized: RealtimeNodeMetric = {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: Number(metric.uptime ?? 0),
};
const incomingUptime = Number(metric.uptime ?? 0);
setRealtimeNodeMetrics((prev) => ({
...prev,
[nodeId]: normalized,
}));
setRealtimeNodeMetrics((prev) => {
const normalized: RealtimeNodeMetric = {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
load1: Number(metric.load1 ?? 0),
load5: Number(metric.load5 ?? 0),
load15: Number(metric.load15 ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
};
return {
...prev,
[nodeId]: normalized,
};
});
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: "online",
@@ -465,8 +612,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return;
}
setMetricsTruncated(false);
setMetricsError(response.msg || "加载指标失败");
if (!silent) toast.error(response.msg || "加载指标失败");
const msg = response.msg || "加载指标失败";
const isTimeout = msg.toLowerCase().includes("timeout");
const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg;
setMetricsError(friendlyMsg);
if (!silent) toast.error(friendlyMsg);
} catch {
setMetricsTruncated(false);
if (!silent) setMetricsError("加载指标失败");
@@ -526,9 +676,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}, []);
const loadMonitorResults = useCallback(
async (monitorId: number, limit = 20) => {
async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => {
try {
const response = await getServiceMonitorResults(monitorId, limit);
const apiOptions = options?.rangeMs != null
? { start: Date.now() - options.rangeMs, end: Date.now() }
: { limit: options?.limit ?? 100 };
const response = await getServiceMonitorResults(monitorId, apiOptions);
if (response.code === 0 && response.data) {
setMonitorResults((prev) => ({
@@ -600,7 +753,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
if (!resultsMonitorId) return;
setResultsLoading(true);
try {
await loadMonitorResults(resultsMonitorId, resultsLimit);
await loadMonitorResults(resultsMonitorId, { limit: resultsLimit });
} finally {
setResultsLoading(false);
}
@@ -616,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const timer = window.setInterval(() => {
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 30_000);
}, 5_000);
return () => window.clearInterval(timer);
}, [loadLatestMonitorResults, loadServiceMonitors]);
@@ -641,13 +794,27 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
void loadResultsForModal();
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
// Reload results for the active service monitor chart when range limit changes
// Auto-load results for the resolved default monitor when entering detail view
useEffect(() => {
if (!detailNodeId) return;
if (activeServiceMonitorId) return; // user already selected one
// Find the first monitor belonging to this node (or panel-level)
const firstMonitor = serviceMonitors.find(
(m) => m.nodeId === detailNodeId || m.nodeId === 0,
);
if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) {
void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailNodeId, serviceMonitors]);
// Reload results for the active service monitor chart when time range changes
useEffect(() => {
if (!activeServiceMonitorId) return;
void loadMonitorResults(activeServiceMonitorId, serviceMonitorRangeLimit);
}, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]);
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
const chartData = metrics.map((m) => ({
const chartData = useMemo(() => metrics.map((m) => ({
time: formatTimestamp(m.timestamp, metricsRangeMs),
cpu: m.cpuUsage,
memory: m.memoryUsage,
@@ -659,52 +826,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
load15: m.load15,
tcp: m.tcpConns,
udp: m.udpConns,
}));
const getChartConfig = () => {
switch (activeMetricType) {
case "cpu":
return {
lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }],
yAxisLabel: "使用率 (%)",
};
case "memory":
return {
lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }],
yAxisLabel: "使用率 (%)",
};
case "disk":
return {
lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }],
yAxisLabel: "使用率 (%)",
};
case "network":
return {
lines: [
{ dataKey: "netIn", color: "#10b981", name: "入站速度" },
{ dataKey: "netOut", color: "#ef4444", name: "出站速度" },
],
yAxisLabel: "速度 (bytes/s)",
};
case "load":
return {
lines: [
{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" },
{ dataKey: "load5", color: "#8b5cf6", name: "负载 5m" },
{ dataKey: "load15", color: "#f59e0b", name: "负载 15m" },
],
yAxisLabel: "负载值",
};
case "connections":
return {
lines: [
{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" },
{ dataKey: "udp", color: "#10b981", name: "UDP 连接" },
],
yAxisLabel: "连接数",
};
}
};
})), [metrics, metricsRangeMs]);
@@ -915,6 +1037,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
);
// Backend batches DB writes every 30s, but latest API reads from in-memory cache.
// The cache timestamp reflects the real check time (every ~1s), so stale detection
// should still allow for the batch report interval + scan jitter.
const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval
const isResultStale = useCallback(
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
if (monitor.enabled !== 1) {
@@ -925,8 +1052,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
// Budget = batch report interval + one check interval + scan jitter + grace
const budgetMs =
intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
return Date.now() - latestResult.timestamp > budgetMs;
},
@@ -966,46 +1094,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return { disabled, ok, fail, unknown, stale };
}, [getLatestResult, isResultStale, serviceMonitors]);
const chartConfig = getChartConfig();
const nodeYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(0)}%`;
case "load":
return n.toFixed(1);
case "connections":
return String(Math.round(n));
}
};
const nodeTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(1)}%`;
case "load":
return n.toFixed(2);
case "connections":
return String(Math.round(n));
}
};
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
@@ -1097,25 +1185,40 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
<div className="flex items-center gap-2">
<DistroIcon distro={parseDistroFromVersion(node.version)} className="w-4 h-4 flex-shrink-0" style={{ color: isOnline ? getDistroColor(parseDistroFromVersion(node.version)) : undefined }} />
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-success-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-success-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-success-50 dark:bg-success-500/10 text-success-500">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
<div className="flex items-center gap-1 font-mono text-primary-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
<div className="flex items-center gap-1.5 font-mono text-primary-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-500">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
</div>
</TableCell>
@@ -1231,83 +1334,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
)}
{/* Node metrics chart */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(metricsRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setMetricsRangeMs(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={metricsLoading} size="sm" variant="flat" onPress={() => selectedNodeId && loadMetrics(selectedNodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{([
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
] as { key: MetricType; label: string }[]).map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => setActiveMetricType(item.key)}
>
{item.label}
</Button>
))}
</div>
{metricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : metricsError ? (
<div className="text-center py-8 text-danger text-sm">{metricsError}</div>
) : metrics.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={nodeYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={nodeTooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{metricsTruncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
<NodeMetricsChartCard
rangeMs={metricsRangeMs}
onRangeChange={setMetricsRangeMs}
activeMetricType={activeMetricType}
onMetricTypeChange={setActiveMetricType}
loading={metricsLoading}
error={metricsError}
truncated={metricsTruncated}
maxRows={METRICS_MAX_ROWS}
data={chartData}
nodeId={selectedNodeId}
onRefresh={loadMetrics}
/>
{/* Service monitors chart – same style as node metrics */}
@@ -1322,10 +1361,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
const activeLatencyData = [...activeResults]
.slice(0, serviceMonitorRangeLimit)
.reverse()
.map((r) => ({
time: formatTimestamp(r.timestamp),
time: formatTimestamp(r.timestamp, serviceMonitorRangeMs),
latency: r.success === 1 ? r.latencyMs : null,
success: r.success,
}));
@@ -1336,24 +1373,23 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<h3 className="text-lg font-semibold">服务监控图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-28"
selectedKeys={[String(serviceMonitorRangeLimit)]}
className="w-36"
selectedKeys={[String(serviceMonitorRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setServiceMonitorRangeLimit(v);
if (v > 0) setServiceMonitorRangeMs(v);
}}
>
<SelectItem key="20">20条</SelectItem>
<SelectItem key="50">50条</SelectItem>
<SelectItem key="100">100条</SelectItem>
<SelectItem key="200">200条</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
{resolvedActiveMonitorId != null && (
<Button
size="sm"
variant="flat"
onPress={() => {
void loadMonitorResults(resolvedActiveMonitorId, serviceMonitorRangeLimit);
void loadMonitorResults(resolvedActiveMonitorId, { rangeMs: serviceMonitorRangeMs });
}}
>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -1391,7 +1427,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
setActiveServiceMonitorId(monitor.id);
// Load results for this monitor if not loaded
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
void loadMonitorResults(monitor.id, serviceMonitorRangeLimit);
void loadMonitorResults(monitor.id, { rangeMs: serviceMonitorRangeMs });
}
}}
>
@@ -1412,7 +1448,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<div className="flex items-center gap-3 min-w-0 flex-wrap">
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
<span className="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
<span className="text-xs text-default-500">间隔 {resolvedActiveMonitor.intervalSec}s</span>
<span className="text-xs text-default-500">每秒测试,30秒上报</span>
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
) : null}
+37 -1
View File
@@ -48,13 +48,49 @@ const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
return null;
};
/**
* Heuristic to verify the parsed object actually contains system-info fields.
* Prevents non-metric messages (command responses, etc.) from being mistakenly
* treated as system info, which would reset all values to 0 and cause UI flicker.
*/
const SYSTEM_INFO_KEYS = [
"uptime",
"cpu_usage",
"memory_usage",
"disk_usage",
"bytes_received",
"bytes_transmitted",
"net_in_speed",
"net_out_speed",
"tcp_conns",
"udp_conns",
"load1",
"load5",
"load15",
] as const;
const looksLikeSystemInfo = (raw: RawSystemInfo): boolean => {
let matched = 0;
for (const key of SYSTEM_INFO_KEYS) {
if (key in raw && raw[key] !== undefined) {
matched++;
if (matched >= 3) {
return true;
}
}
}
return false;
};
export const buildNodeSystemInfo = (
messageData: unknown,
previous: NodeSystemInfo | null | undefined,
): NodeSystemInfo | null => {
const raw = parseRawSystemInfo(messageData);
if (!raw) {
if (!raw || !looksLikeSystemInfo(raw)) {
return null;
}
@@ -2,9 +2,10 @@ import type {
MonitorTunnelApiItem,
TunnelMetricApiItem,
TunnelQualityApiItem,
TunnelQualityHopApiItem,
} from "@/api/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LineChart,
Line,
@@ -23,6 +24,7 @@ import {
ArrowRightLeft,
Wifi,
WifiOff,
ArrowRight,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -31,8 +33,9 @@ import {
getTunnelMetrics,
getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api";
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip";
@@ -50,7 +53,10 @@ interface TunnelMonitorViewProps {
viewMode?: "list" | "grid";
}
const QUALITY_POLL_INTERVAL = 10_000; // 10 seconds
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
@@ -72,17 +78,6 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
});
};
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
/** Render a colored latency value with appropriate visual cue */
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
@@ -99,7 +94,6 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
}
/** Animated pulse dot for live status */
function LiveDot() {
return (
<span className="relative flex h-2 w-2">
@@ -109,6 +103,289 @@ function LiveDot() {
);
}
const UPTIME_KUMA_BARS_COUNT = 30;
function UptimeHistoryBar({
history = [],
type,
latestValue,
}: {
history: TunnelQualityApiItem[];
type: "entryToExit" | "exitToBing";
latestValue?: number;
}) {
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
if (historyIndex >= 0 && historyIndex < history.length) {
return history[historyIndex];
}
return null;
});
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-[3px]">
{bars.map((q, idx) => {
if (!q) {
return (
<div
key={idx}
className="w-1.5 h-4 rounded-full bg-default-200 dark:bg-default-100/50"
title="暂无数据"
/>
);
}
const isEntry = type === "entryToExit";
const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency;
const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss;
const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN");
let colorClass = "bg-success";
let statusText = "优秀";
if (q.errorMessage || (loss !== undefined && loss > 0)) {
colorClass = "bg-danger";
statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`;
} else if (latency < 0) {
colorClass = "bg-danger";
statusText = "探测失败";
} else if (latency > 200) {
colorClass = "bg-danger";
statusText = "极差";
} else if (latency > 100) {
colorClass = "bg-warning";
statusText = "较差";
}
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
return (
<div
key={q.timestamp || idx}
className={`w-1.5 h-4 rounded-full transition-colors cursor-help hover:opacity-80 ${colorClass}`}
title={tooltip}
/>
);
})}
</div>
<div>
<LatencyDisplay value={latestValue} />
</div>
</div>
);
}
const TIME_RANGE_OPTIONS = [
{ key: String(15 * 60 * 1000), label: "15分钟" },
{ key: String(60 * 60 * 1000), label: "1小时" },
{ key: String(6 * 60 * 60 * 1000), label: "6小时" },
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
];
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
{TIME_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.key}>{opt.label}</SelectItem>
))}
</Select>
);
}
interface QualityChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: QualityChartCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
);
});
interface TrafficChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: TrafficChartCardProps) {
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={yFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
);
});
function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
if (!hopsStr) return null;
let hops: TunnelQualityHopApiItem[] = [];
try {
hops = JSON.parse(hopsStr);
} catch {
return null;
}
if (!Array.isArray(hops) || hops.length === 0) return null;
return (
<Card className="border border-divider/60 shadow-sm transition-shadow bg-gradient-to-br from-background to-default-50/50 mt-4">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between pb-1">
<h3 className="text-sm font-semibold flex items-center gap-1.5 text-default-700">
<Activity className="w-4 h-4 text-primary" />
全链路拓扑状态 (实时)
</h3>
</CardHeader>
<CardBody className="py-2 px-4 pb-4">
<div className="flex items-center overflow-x-auto pb-2 py-2">
{hops.map((hop, index) => {
const hasError = hop.latency < 0 || hop.loss > 0;
const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
const borderColor = hasError ? "border-danger" : "";
return (
<React.Fragment key={index}>
{index === 0 && (
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
{hop.fromNodeName}
</Chip>
)}
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
</span>
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
</div>
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
{hop.loss.toFixed(0)}% 丢包
</span>
</div>
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
{hop.toNodeName}
</Chip>
</React.Fragment>
);
})}
</div>
</CardBody>
</Card>
);
}
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
@@ -117,8 +394,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// Quality data from backend periodic probing (latest per tunnel)
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
// Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
@@ -166,9 +447,25 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}
}, []);
const loadMonitorTunnelQualityEnabled = useCallback(async () => {
try {
const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
);
setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string"
? response.data.value === "true"
: true,
);
} catch {
setMonitorTunnelQualityEnabled(true);
}
}, []);
useEffect(() => {
void loadTunnels();
}, [loadTunnels]);
void loadMonitorTunnelQualityEnabled();
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -178,6 +475,70 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer);
}, [loadTunnels]);
useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled);
if (!enabled) {
setQualityLoading(false);
}
} else {
void loadMonitorTunnelQualityEnabled();
}
};
window.addEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
return () => {
window.removeEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
};
}, [loadMonitorTunnelQualityEnabled]);
useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true;
const fetchHistory = async () => {
const end = Date.now();
// Fetch last 5 minutes (about 30 points)
const start = end - 5 * 60 * 1000;
const promises = tunnels.map((t) =>
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
if (res.code === 0 && res.data) {
return { id: t.id, data: res.data };
}
return { id: t.id, data: [] };
}).catch(() => ({ id: t.id, data: [] }))
);
const results = await Promise.all(promises);
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
results.forEach(r => {
const existing = nextMap[r.id] || [];
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
const unique: TunnelQualityApiItem[] = [];
for (const item of merged) {
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
unique.push(item);
}
}
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
});
return nextMap;
});
};
void fetchHistory();
}
}, [tunnels]);
// --- Load quality snapshots (auto-polling every 10s) ---
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
@@ -190,6 +551,19 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
map[q.tunnelId] = q;
}
setQualityMap(map);
// Accumulate locally
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
for (const q of response.data) {
const currentArr = nextMap[q.tunnelId] || [];
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
if (!lastItem || lastItem.timestamp !== q.timestamp) {
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
}
}
return nextMap;
});
}
} catch {
// Silently ignore quality load failures
@@ -199,10 +573,22 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}, []);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
return;
}
void loadQuality();
}, [loadQuality]);
}, [loadQuality, monitorTunnelQualityEnabled]);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
qualityTimerRef.current = window.setInterval(() => {
void loadQuality({ silent: true });
}, QUALITY_POLL_INTERVAL);
@@ -210,9 +596,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
};
}, [loadQuality]);
}, [loadQuality, monitorTunnelQualityEnabled]);
// --- Load quality history for detail chart ---
const loadQualityHistory = useCallback(
@@ -276,50 +663,57 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => {
if (detailTunnelId) {
void loadQualityHistory(detailTunnelId);
void loadTunnelMetrics(detailTunnelId);
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId);
}
}
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Auto-refresh detail charts
useEffect(() => {
if (!detailTunnelId) return;
const timer = window.setInterval(() => {
void loadQualityHistory(detailTunnelId, { silent: true });
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId, { silent: true });
}
void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Chart data for quality history
const qualityChartData = qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
}));
// Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo(
() => qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
[qualityHistory, qualityRangeMs],
);
// Chart data for traffic metrics
const tunnelChartData = tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
}));
const tunnelYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
return formatBytes(n);
};
const tunnelTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
return formatBytes(n);
};
const tunnelChartData = useMemo(
() => tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
[tunnelMetrics, tunnelRangeMs],
);
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
@@ -341,27 +735,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]);
/** Shared time range Select component */
const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
);
// =====================
// RENDER
// =====================
if (accessDenied) {
return (
<Card>
@@ -379,7 +752,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
);
}
// ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) {
const quality = qualityMap[detailTunnelId];
@@ -444,8 +816,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500">
<LiveDot />
<span>自动探测中(每10秒)</span>
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时隧道质量检测已关闭</span>
</>
)}
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
@@ -456,123 +837,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)}
</div>
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
<Button
isLoading={qualityHistoryLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{qualityHistoryLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : qualityHistoryError ? (
<div className="text-center py-8 text-danger text-sm">{qualityHistoryError}</div>
) : qualityChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={qualityChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
{/* ====== Chain Topology ====== */}
{monitorTunnelQualityEnabled && quality?.chainDetails && (
<ForwardingChainTopology hopsStr={quality.chainDetails} />
)}
<Line
connectNulls
dataKey="entryToExit"
dot={false}
name="entryToExit"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
connectNulls
dataKey="exitToBing"
dot={false}
name="exitToBing"
stroke="#3b82f6"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}
onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
tunnelId={detailTunnelId}
onRefresh={loadQualityHistory}
/>
{/* ====== Traffic Chart (unchanged) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={tunnelRangeMs} onChange={setTunnelRangeMs} />
<Button
isLoading={tunnelMetricsLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadTunnelMetrics(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{tunnelMetricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : tunnelMetricsError ? (
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
) : tunnelChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={tunnelChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={tunnelTooltipFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
{/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard
rangeMs={tunnelRangeMs}
onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
tunnelId={detailTunnelId}
onRefresh={loadTunnelMetrics}
/>
</div>
);
}
@@ -582,12 +872,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{lastQualityUpdate && (
{lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<LiveDot />
<span>自动探测 · 更新于 {lastQualityUpdate}</span>
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
</>
)}
</div>
)}
) : !monitorTunnelQualityEnabled ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭</span>
</div>
) : null}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -609,9 +913,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<Card
@@ -637,11 +938,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div>
</div>
</div>
{overallQuality && (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
)}
</CardHeader>
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
@@ -651,14 +947,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Zap className="w-3 h-3" />
入口→出口
</div>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</div>
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" />
出口→Bing
</div>
<LatencyDisplay value={quality?.exitToBingLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</div>
</div>
@@ -667,11 +963,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1">
<LiveDot />
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-[11px] text-default-400">等待探测...</span>
<span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
</span>
)}
</div>
</CardBody>
@@ -695,16 +997,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableColumn>名称</TableColumn>
<TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn>
<TableColumn>质量</TableColumn>
<TableColumn>更新时间</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道">
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
@@ -721,28 +1019,25 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{overallQuality ? (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
) : (
<span className="text-xs text-default-400">-</span>
)}
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{quality?.timestamp ? (
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
<LiveDot />
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-xs text-default-400">-</span>
<span className="text-xs text-default-400">
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
</span>
)}
</TableCell>
</TableRow>
+34 -30
View File
@@ -32,6 +32,9 @@ interface PanelAddress {
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
typeof value === "string" ? value === "true" : defaultValue;
export const SettingsPage = () => {
const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
@@ -50,9 +53,16 @@ export const SettingsPage = () => {
setPanelAddresses(newAddress);
};
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表
const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses();
};
@@ -72,7 +82,6 @@ export const SettingsPage = () => {
return;
}
(window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim());
setNewName("");
setNewAddress("");
@@ -81,14 +90,12 @@ export const SettingsPage = () => {
// 设置当前面板地址
const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name);
reinitializeBaseURL();
toast.success("删除成功");
@@ -103,12 +110,7 @@ export const SettingsPage = () => {
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled =
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
} catch {
setForwardCompactMode(false);
}
@@ -216,28 +218,30 @@ export const SettingsPage = () => {
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</CardBody>
</Card>
+23 -20
View File
@@ -418,7 +418,7 @@ export default function TunnelPage() {
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes);
const newErrors = validateTunnelForm(form, nodes, isEdit);
setErrors(newErrors);
@@ -1578,6 +1578,7 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
@@ -1598,7 +1599,7 @@ export default function TunnelPage() {
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn align="center">操作</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
@@ -1616,7 +1617,18 @@ export default function TunnelPage() {
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell className="font-medium text-foreground">{tunnel.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
tunnel.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{tunnel.name}
</span>
</div>
</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
@@ -1656,40 +1668,30 @@ export default function TunnelPage() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
color="primary"
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
编辑
</Button>
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd" />
</svg>
诊断
</Button>
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" fillRule="evenodd" />
<path clipRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" fillRule="evenodd" />
</svg>
删除
</Button>
</div>
</TableCell>
@@ -1698,6 +1700,7 @@ export default function TunnelPage() {
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
+12 -7
View File
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
export const validateTunnelForm = (
form: TunnelFormInput,
nodes: TunnelNodeInput[],
isEdit = false,
): Record<string, string> => {
const errors: Record<string, string> = {};
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
if (!form.inNodeId || form.inNodeId.length === 0) {
errors.inNodeId = "请至少选择一个入口节点";
} else {
} else if (!isEdit) {
// Only enforce online check for new tunnels. During edit the backend
// allows existing offline nodes (user may be removing them).
const offlineInNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
@@ -64,14 +67,16 @@ export const validateTunnelForm = (
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
if (!isEdit) {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
return node && node.status !== 1;
});
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
}
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
@@ -1,62 +1,43 @@
import * as React from "react";
/**
* useTheme — backwards-compatible hook
* =====================================
* Wraps the new theme system's context to provide the same API that the
* rest of the codebase already expects: `{ theme, setTheme }`.
*
* For full theme system access, use `useThemeContext` from "@/themes".
*/
type ThemeMode = "light" | "dark";
import { useSyncExternalStore, useCallback } from "react";
const STORAGE_KEY = "flvx:theme";
import {
subscribe,
getSavedMode,
getEffectiveMode,
saveMode,
reapplyActiveTheme,
type ThemeMode,
} from "@/themes/registry";
function resolveInitialTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
if (fromStorage === "dark" || fromStorage === "light") {
return fromStorage;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
let currentTheme: ThemeMode = resolveInitialTheme();
const listeners = new Set<(theme: ThemeMode) => void>();
function broadcast(theme: ThemeMode) {
currentTheme = theme;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, theme);
}
listeners.forEach((listener) => {
listener(theme);
// Monotonic counter for snapshot identity
let _rev = 0;
const _sub = (cb: () => void) =>
subscribe(() => {
_rev++;
cb();
});
}
const _snap = () => _rev;
export function useTheme() {
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
useSyncExternalStore(_sub, _snap);
React.useEffect(() => {
const listener = (nextTheme: ThemeMode) => {
setThemeState(nextTheme);
};
const theme = getEffectiveMode();
const mode = getSavedMode();
listeners.add(listener);
return () => {
listeners.delete(listener);
};
const setTheme = useCallback((next: string) => {
if (next !== "dark" && next !== "light" && next !== "system") return;
saveMode(next as ThemeMode);
reapplyActiveTheme();
}, []);
const setTheme = React.useCallback((nextTheme: string) => {
if (nextTheme !== "dark" && nextTheme !== "light") {
return;
}
broadcast(nextTheme);
}, []);
return {
setTheme,
theme,
};
return { theme, mode, setTheme };
}
+382
View File
@@ -0,0 +1,382 @@
# FLVX 主题开发指南
## 概述
FLVX 主题系统允许你完全自定义前端的外观和行为。一个主题可以覆盖:
| 覆盖层级 | 说明 | 难度 |
|----------|------|------|
| **CSS 变量** | 修改颜色、字体、圆角等设计 token | ⭐ 简单 |
| **原始 CSS** | 注入自定义 CSS(动画、字体、阴影等) | ⭐⭐ 中等 |
| **组件替换** | 替换任意 UI 组件(按钮、卡片、输入框等) | ⭐⭐⭐ 高级 |
| **布局替换** | 替换整个页面布局结构 | ⭐⭐⭐ 高级 |
| **页面替换** | 替换整个页面实现 | ⭐⭐⭐⭐ 专家 |
## 快速开始
### 1. 创建主题文件夹
```
src/themes/my-theme/
├── index.ts ← 必须:主题入口,导出 ThemePackage
├── components/ ← 可选:组件覆盖
│ ├── index.ts
│ └── button.tsx
├── layouts/ ← 可选:布局覆盖
│ └── admin.tsx
├── pages/ ← 可选:页面覆盖
│ └── login.tsx
├── assets/ ← 可选:图片、字体等资源
└── styles.css ← 可选:额外样式文件
```
### 2. 编写主题入口 `index.ts`
```typescript
import type { ThemePackage } from "../types";
const myTheme: ThemePackage = {
id: "my-theme", // 唯一标识(kebab-case)
name: "我的主题", // 显示名称
author: "Your Name", // 作者
version: "1.0.0", // 版本号
description: "一个自定义主题",
// CSS 变量覆盖
tokens: {
light: {
"--primary": "#ff6600",
"--primary-foreground": "#ffffff",
"--background": "#fafafa",
},
dark: {
"--primary": "#ff8833",
"--background": "#1a1a2e",
},
},
};
export default myTheme;
```
### 3. 注册主题
打开 `src/themes/loader.ts`,添加两行:
```typescript
import myTheme from "./my-theme";
registerTheme(myTheme);
```
完成!主题已可用。
---
## 详细指南
### CSS 变量覆盖
所有可用的 CSS 变量定义在 `src/themes/types.ts` 的 `ThemeTokens` 接口中。常用的:
```typescript
tokens: {
light: {
// 基础色
"--background": "#ffffff", // 页面背景
"--foreground": "#000000", // 文字颜色
"--border": "#e5e7eb", // 边框颜色
"--content1": "#ffffff", // 卡片背景
// 品牌色
"--primary": "#2563eb", // 主色
"--primary-foreground": "#fff", // 主色上的文字
"--secondary": "#6366f1", // 辅色
// 状态色
"--danger": "#dc2626",
"--success": "#16a34a",
"--warning": "#d97706",
// 每种品牌色都有 50-900 共 10 级色阶
"--primary-50": "#eff6ff", // 最浅
"--primary-500": "#3b82f6", // 中间
"--primary-900": "#1e3a8a", // 最深
// 字体
"--font-sans": '"Inter", sans-serif',
"--font-mono": '"Fira Code", monospace',
// 圆角
"--radius": "0.5rem",
},
dark: {
// 暗色模式下的覆盖...
},
}
```
> **提示**: 你不需要定义所有变量,只定义你想修改的,其余沿用默认值。
### 原始 CSS 注入
`css` 字段可以注入任意 CSS。主题激活时会插入一个 `<style>` 标签,停用时自动移除。
```typescript
const theme: ThemePackage = {
// ...
css: `
/* 自定义字体 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap');
body {
font-family: 'Noto Sans SC', sans-serif;
}
/* 自定义动画 */
@keyframes my-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 给所有卡片加阴影 */
.rounded-xl, .rounded-lg {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
/* 自定义滚动条 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
}
`,
};
```
### 组件替换
可以替换任意 UI 组件。替换组件**必须接受与原组件相同的 props**。
#### 可替换的组件列表
| 组件键名 | 原始位置 | 说明 |
|----------|----------|------|
| `Button` | `shadcn-bridge/heroui/button` | 按钮 |
| `Card`, `CardHeader`, `CardBody`, `CardFooter` | `shadcn-bridge/heroui/card` | 卡片 |
| `Input` | `shadcn-bridge/heroui/input` | 输入框 |
| `Select`, `SelectItem` | `shadcn-bridge/heroui/select` | 下拉选择 |
| `Switch` | `shadcn-bridge/heroui/switch` | 开关 |
| `Checkbox` | `shadcn-bridge/heroui/checkbox` | 复选框 |
| `Chip` | `shadcn-bridge/heroui/chip` | 标签/芯片 |
| `Modal`, `ModalContent`, `ModalHeader`, `ModalBody`, `ModalFooter` | `shadcn-bridge/heroui/modal` | 模态框 |
| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`, `TableColumn` | `shadcn-bridge/heroui/table` | 表格 |
| `Tabs`, `Tab` | `shadcn-bridge/heroui/tabs` | 标签页 |
| `Progress` | `shadcn-bridge/heroui/progress` | 进度条 |
| `Spinner` | `shadcn-bridge/heroui/spinner` | 加载指示器 |
| `Divider` | `shadcn-bridge/heroui/divider` | 分割线 |
| `Link` | `shadcn-bridge/heroui/link` | 链接 |
| `Dropdown`, `DropdownTrigger`, `DropdownMenu`, `DropdownItem` | `shadcn-bridge/heroui/dropdown` | 下拉菜单 |
| `Navbar`, `NavbarContent`, `NavbarItem` | `shadcn-bridge/heroui/navbar` | 导航栏 |
| `Radio`, `RadioGroup` | `shadcn-bridge/heroui/radio` | 单选框 |
| `Accordion`, `AccordionItem` | `shadcn-bridge/heroui/accordion` | 手风琴 |
| `DatePicker` | `shadcn-bridge/heroui/date-picker` | 日期选择器 |
| `Alert` | `shadcn-bridge/heroui/alert` | 警告提示 |
| `SearchBar` | `components/search-bar` | 搜索栏 |
| `BrandLogo` | `components/brand-logo` | 品牌 Logo |
| `VersionFooter` | `components/version-footer` | 版本页脚 |
#### 组件替换示例
**方式一:包装原组件**(推荐,保证兼容性)
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = (props) => {
return (
<OriginalButton
{...props}
className={`${props.className || ""} my-custom-class`}
style={{
...props.style,
borderRadius: "9999px", // 全圆角
}}
/>
);
};
```
**方式二:完全重写组件**
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = ({
children,
color = "default",
variant = "solid",
size = "md",
isLoading,
isDisabled,
onPress,
className,
...rest
}) => {
return (
<button
className={`my-totally-custom-button ${className || ""}`}
disabled={isDisabled || isLoading}
onClick={() => onPress?.()}
{...rest}
>
{isLoading && <span className="spinner" />}
{children}
</button>
);
};
```
然后在主题入口中注册:
```typescript
// src/themes/my-theme/index.ts
import { MyButton } from "./components/button";
const theme: ThemePackage = {
// ...
components: {
Button: MyButton,
},
};
```
### 布局替换
可以替换 4 种布局:
| 布局键名 | 说明 |
|----------|------|
| `AdminLayout` | 管理后台主布局(侧边栏 + 顶栏) |
| `H5Layout` | 移动端布局(底部导航) |
| `H5SimpleLayout` | 移动端简洁布局(无底部导航) |
| `DefaultLayout` | 默认布局(登录页等) |
```typescript
// src/themes/my-theme/layouts/admin.tsx
import React from "react";
const MyAdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="my-admin-layout">
<header className="my-header">
{/* 自定义顶栏 */}
</header>
<aside className="my-sidebar">
{/* 自定义侧边栏 */}
</aside>
<main className="my-content">
{children}
</main>
</div>
);
};
export default MyAdminLayout;
```
### 页面替换
可以替换任意页面路由的实现:
| 页面键名 | 路由 |
|----------|------|
| `LoginPage` | `/` |
| `DashboardPage` | `/dashboard` |
| `MonitorPage` | `/monitor` |
| `ForwardPage` | `/forward` |
| `TunnelPage` | `/tunnel` |
| `NodePage` | `/node` |
| `UserPage` | `/user` |
| `GroupPage` | `/group` |
| `ProfilePage` | `/profile` |
| `LimitPage` | `/limit` |
| `ConfigPage` | `/config` |
| `PanelSharingPage` | `/panel-sharing` |
| `SettingsPage` | `/settings` |
### 生命周期钩子
```typescript
const theme: ThemePackage = {
// ...
onActivate: () => {
// 主题被激活时执行
// 例如:加载外部字体、注入全局属性
console.log("Theme activated!");
},
onDeactivate: () => {
// 主题被停用时执行
// 例如:清理全局属性
console.log("Theme deactivated!");
},
};
```
---
## 主题提交流程
1. Fork 本仓库
2. 在 `src/themes/` 下创建你的主题文件夹
3. 在 `src/themes/loader.ts` 中注册
4. 提交 Pull Request
### 命名规范
- 文件夹名:`kebab-case`(如 `my-awesome-theme`)
- 主题 `id`:与文件夹名一致
- 主题 `name`:简短中文名
### 代码规范
- TypeScript 严格模式
- 组件替换必须保证 props 兼容性
- 不得修改 `src/themes/types.ts`(影响其他主题)
- 不得修改 `src/themes/registry.ts`(影响核心逻辑)
- 仅修改你自己的主题文件夹 + `loader.ts` 中的注册
---
## 文件结构参考
```
src/themes/
├── types.ts # 主题接口定义 (勿改)
├── registry.ts # 主题注册表 (勿改)
├── context.tsx # React Context (勿改)
├── index.ts # 公共 API (勿改)
├── loader.ts # 主题加载器 (仅在此添加注册)
│
├── default/ # 默认主题 (参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题 (可复制修改)
│ ├── index.ts
│ └── components/
│ └── button.tsx
│
└── your-theme/ # 你的主题
├── index.ts
├── components/
│ ├── button.tsx
│ └── card.tsx
├── layouts/
│ └── admin.tsx
└── assets/
└── logo.svg
```
+188
View File
@@ -0,0 +1,188 @@
/**
* Theme Context + Provider (React integration)
* =============================================
* Wraps the registry in a React context so that the entire component tree
* re-renders when the active theme changes.
*/
import React, {
createContext,
useContext,
useEffect,
useSyncExternalStore,
useCallback,
useMemo,
} from "react";
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import {
subscribe,
getActiveTheme,
getActiveThemeId,
getRegisteredThemes,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
registerTheme,
unregisterTheme,
type ThemeMode,
} from "./registry";
// ─── context value ───────────────────────────────────────────────────────────
interface ThemeContextValue {
/** Currently active theme package (null = default). */
activeTheme: ThemePackage | null;
activeThemeId: string | null;
/** All registered themes. */
themes: ThemePackage[];
/** Current mode preference. */
mode: ThemeMode;
/** Resolved effective mode (never "system"). */
effectiveMode: "light" | "dark";
/** Switch the active theme. */
switchTheme: (id: string) => void;
/** Reset to no custom theme (use defaults). */
resetTheme: () => void;
/** Change mode preference. */
setMode: (mode: ThemeMode) => void;
/** Register a new theme at runtime. */
register: (pkg: ThemePackage) => void;
/** Unregister a theme by id. */
unregister: (id: string) => void;
/** Resolve a component (returns themed override or fallback). */
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
/** Resolve a layout. */
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
/** Resolve a page. */
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
// ─── snapshot for useSyncExternalStore ────────────────────────────────────────
// We use a monotonic counter to create new snapshot references when the
// registry notifies.
let snapshotCounter = 0;
function getSnapshot() {
return snapshotCounter;
}
const originalSubscribe = (onStoreChange: () => void) => {
const unsub = subscribe(() => {
snapshotCounter++;
onStoreChange();
});
return unsub;
};
// ─── provider ────────────────────────────────────────────────────────────────
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Re-render whenever register changes
useSyncExternalStore(originalSubscribe, getSnapshot);
// Listen for system mode changes
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => reapplyActiveTheme();
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const switchTheme = useCallback((id: string) => activateTheme(id), []);
const resetTheme = useCallback(() => {
deactivateTheme();
reapplyActiveTheme();
localStorage.removeItem("flvx:active-theme");
}, []);
const setMode = useCallback((m: ThemeMode) => {
saveMode(m);
reapplyActiveTheme();
}, []);
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
const unregister = useCallback((id: string) => unregisterTheme(id), []);
const value = useMemo<ThemeContextValue>(() => ({
activeTheme: getActiveTheme(),
activeThemeId: getActiveThemeId(),
themes: getRegisteredThemes(),
mode: getSavedMode(),
effectiveMode: getEffectiveMode(),
switchTheme,
resetTheme,
setMode,
register,
unregister,
component: resolveComponent,
layout: resolveLayout,
page: resolvePage,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// ─── hooks ───────────────────────────────────────────────────────────────────
/** Access the full theme context. */
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
return ctx;
}
/**
* Convenience: resolve a single themed component.
*
* ```tsx
* import { useThemedComponent } from "@/themes/context";
* import { Button as DefaultButton } from "@/shadcn-bridge/heroui/button";
*
* function MyPage() {
* const Button = useThemedComponent("Button", DefaultButton);
* return <Button color="primary">Click</Button>;
* }
* ```
*/
export function useThemedComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.component(key, fallback);
}
/** Convenience: resolve a themed layout. */
export function useThemedLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const ctx = useThemeContext();
return ctx.layout(key, fallback);
}
/** Convenience: resolve a themed page. */
export function useThemedPage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.page(key, fallback);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Default Theme
* =============
* This is the built-in "stock" FLVX theme. It doesn't override any
* components — it only declares the CSS tokens that match the colours
* already defined in `globals.css`. This serves as the **reference
* implementation** that theme authors can copy and modify.
*
* When this theme is active, the frontend looks identical to an
* unmodified FLVX install.
*/
import type { ThemePackage } from "../types";
const defaultTheme: ThemePackage = {
id: "default",
name: "默认主题",
author: "FLVX Team",
version: "1.0.0",
description: "FLVX 内置默认蓝色主题",
tokens: {
light: {
"--background": "#f6f7fb",
"--foreground": "#111827",
"--border": "#e5e7eb",
"--input": "#d1d5db",
"--ring": "#93c5fd",
"--content1": "#ffffff",
"--divider": "#e5e7eb",
"--default-50": "#f9fafb",
"--default-100": "#f3f4f6",
"--default-200": "#e5e7eb",
"--default-300": "#d1d5db",
"--default-400": "#9ca3af",
"--default-500": "#6b7280",
"--default-600": "#4b5563",
"--default-700": "#374151",
"--default-800": "#1f2937",
"--default-900": "#111827",
"--primary": "#2563eb",
"--primary-foreground": "#ffffff",
"--primary-50": "#eff6ff",
"--primary-100": "#dbeafe",
"--primary-200": "#bfdbfe",
"--primary-300": "#93c5fd",
"--primary-400": "#60a5fa",
"--primary-500": "#3b82f6",
"--primary-600": "#2563eb",
"--primary-700": "#1d4ed8",
"--primary-800": "#1e40af",
"--primary-900": "#1e3a8a",
"--secondary": "#6366f1",
"--secondary-foreground": "#ffffff",
"--secondary-50": "#eef2ff",
"--secondary-100": "#e0e7ff",
"--secondary-200": "#c7d2fe",
"--secondary-300": "#a5b4fc",
"--secondary-400": "#818cf8",
"--secondary-500": "#6366f1",
"--secondary-600": "#4f46e5",
"--secondary-700": "#4338ca",
"--secondary-800": "#3730a3",
"--secondary-900": "#312e81",
"--danger": "#dc2626",
"--danger-50": "#fef2f2",
"--danger-100": "#fee2e2",
"--danger-200": "#fecaca",
"--danger-300": "#fca5a5",
"--danger-400": "#f87171",
"--danger-500": "#ef4444",
"--danger-600": "#dc2626",
"--danger-700": "#b91c1c",
"--danger-800": "#991b1b",
"--danger-900": "#7f1d1d",
"--success": "#16a34a",
"--success-50": "#f0fdf4",
"--success-100": "#dcfce7",
"--success-200": "#bbf7d0",
"--success-300": "#86efac",
"--success-400": "#4ade80",
"--success-500": "#22c55e",
"--success-600": "#16a34a",
"--success-700": "#15803d",
"--success-800": "#166534",
"--success-900": "#14532d",
"--warning": "#d97706",
"--warning-50": "#fffbeb",
"--warning-100": "#fef3c7",
"--warning-200": "#fde68a",
"--warning-300": "#fcd34d",
"--warning-400": "#fbbf24",
"--warning-500": "#f59e0b",
"--warning-600": "#d97706",
"--warning-700": "#b45309",
"--warning-800": "#92400e",
"--warning-900": "#78350f",
},
dark: {
"--background": "#0b1020",
"--foreground": "#f3f4f6",
"--border": "#334155",
"--input": "#475569",
"--ring": "#60a5fa",
"--content1": "#111827",
"--divider": "#334155",
"--default-50": "#0f172a",
"--default-100": "#1e293b",
"--default-200": "#334155",
"--default-300": "#475569",
"--default-400": "#64748b",
"--default-500": "#94a3b8",
"--default-600": "#cbd5e1",
"--default-700": "#e2e8f0",
"--default-800": "#f1f5f9",
"--default-900": "#f8fafc",
"--primary": "#3b82f6",
"--secondary": "#818cf8",
"--danger": "#ef4444",
"--success": "#22c55e",
"--warning": "#f59e0b",
},
},
// No component/layout/page overrides — uses all defaults.
};
export default defaultTheme;
@@ -0,0 +1,55 @@
/**
* Cyberpunk Button — Component Override Example
* ==============================================
* Demonstrates how to override a built-in component.
*
* Rules:
* 1. Accept the SAME props as the original component.
* 2. Import the original's props type for compatibility.
* 3. You CAN wrap the original component and add extra behaviour,
* or you can build a completely new component from scratch.
*/
import React from "react";
// Import the original Button's props interface for compatibility
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
// Optionally import the original to wrap it
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
/**
* CyberpunkButton wraps the original Button and adds a neon glow effect.
* It passes all props through, so it's a full drop-in replacement.
*/
export const CyberpunkButton: React.FC<ButtonProps> = (props) => {
const { className = "", style, color, ...rest } = props;
// Add neon glow based on color
const glowColor =
color === "danger"
? "rgba(255, 51, 102, 0.5)"
: color === "success"
? "rgba(0, 255, 136, 0.5)"
: color === "warning"
? "rgba(255, 170, 0, 0.5)"
: color === "secondary"
? "rgba(0, 255, 255, 0.5)"
: "rgba(255, 0, 255, 0.5)";
const glowStyle: React.CSSProperties = {
...style,
boxShadow: `0 0 8px ${glowColor}, 0 0 16px ${glowColor}`,
transition: "box-shadow 0.3s ease, transform 0.15s ease",
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
};
return (
<OriginalButton
className={`${className} cyberpunk-btn`}
color={color}
style={glowStyle}
{...rest}
/>
);
};
@@ -0,0 +1,133 @@
/**
* Example Theme: Cyberpunk
* ========================
* A neon-cyberpunk dark theme that demonstrates ALL override capabilities:
* ✅ CSS tokens (full dark palette)
* ✅ Raw CSS (neon glow effects, custom animations, font-face)
* ✅ Component override (custom Button with glow)
* ✅ Lifecycle hooks
*
* Theme authors: copy this entire folder and modify it to create your own
* theme. See README.md for the full guide.
*/
import type { ThemePackage } from "../types";
import { CyberpunkButton } from "./components/button";
const cyberpunkTheme: ThemePackage = {
id: "cyberpunk",
name: "赛博朋克",
author: "FLVX Community",
version: "1.0.0",
description: "霓虹灯风格的赛博朋克暗色主题",
// ── CSS Tokens ────────────────────────────────────────────────────────────
tokens: {
light: {
// This theme is dark-only, so the light tokens just fall through to dark
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
dark: {
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--default-50": "#0d0d20",
"--default-100": "#14142e",
"--default-200": "#1e1e3c",
"--default-300": "#2a2a4a",
"--default-400": "#4a4a6a",
"--default-500": "#7a7a9a",
"--default-600": "#9a9aba",
"--default-700": "#babada",
"--default-800": "#dadaf0",
"--default-900": "#f0f0ff",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--primary-50": "#1a001a",
"--primary-100": "#330033",
"--primary-200": "#660066",
"--primary-300": "#990099",
"--primary-400": "#cc00cc",
"--primary-500": "#ff00ff",
"--primary-600": "#ff33ff",
"--primary-700": "#ff66ff",
"--primary-800": "#ff99ff",
"--primary-900": "#ffccff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
},
// ── Raw CSS (glow effects, animations, fonts) ─────────────────────────────
css: `
/* Cyberpunk neon glow on primary buttons */
[data-flvx-theme="cyberpunk"] .bg-primary,
.bg-primary {
box-shadow: 0 0 12px rgba(255, 0, 255, 0.4),
0 0 24px rgba(255, 0, 255, 0.15);
}
/* Neon border glow on cards */
[data-flvx-theme="cyberpunk"] [class*="border"] {
border-color: rgba(255, 0, 255, 0.15);
}
/* Scanline overlay animation */
@keyframes flvx-scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0a0a1a; }
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #ff00ff, #00ffff);
border-radius: 3px;
}
`,
// ── Component Overrides ───────────────────────────────────────────────────
components: {
Button: CyberpunkButton,
},
// ── Lifecycle ─────────────────────────────────────────────────────────────
onActivate: () => {
// Force dark mode for this theme
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
// Mark the body for theme-specific CSS selectors
document.body.setAttribute("data-flvx-theme", "cyberpunk");
},
onDeactivate: () => {
document.body.removeAttribute("data-flvx-theme");
},
};
export default cyberpunkTheme;
+45
View File
@@ -0,0 +1,45 @@
/**
* @module @/themes
* Public API for the FLVX theme system.
*
* Usage:
* import { ThemeProvider, useThemeContext, registerTheme } from "@/themes";
*/
export type {
ThemePackage,
ThemeTokens,
ComponentKey,
LayoutKey,
PageKey,
} from "./types";
export {
registerTheme,
unregisterTheme,
getRegisteredThemes,
getTheme,
getActiveThemeId,
getActiveTheme,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
initThemeSystem,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
subscribe,
} from "./registry";
export type { ThemeMode } from "./registry";
export {
ThemeProvider,
useThemeContext,
useThemedComponent,
useThemedLayout,
useThemedPage,
} from "./context";
+36
View File
@@ -0,0 +1,36 @@
/**
* Theme Loader — auto-registers all built-in themes
* ==================================================
* Import this module once at app startup (in provider.tsx or App.tsx).
*
* To add a new theme:
* 1. Create a folder under `src/themes/` (e.g. `src/themes/my-theme/`)
* 2. Export a `ThemePackage` as the default export from `index.ts`
* 3. Import and register it below
*/
import { registerTheme } from "./registry";
// ── Built-in themes ──────────────────────────────────────────────────────────
import defaultTheme from "./default";
import cyberpunkTheme from "./example-cyberpunk";
// Register all themes
registerTheme(defaultTheme);
registerTheme(cyberpunkTheme);
/*
* ── ADDING YOUR OWN THEME ──────────────────────────────────────────────────
*
* 1. Create your theme folder:
* src/themes/my-awesome-theme/
* ├── index.ts ← exports ThemePackage
* ├── components/ ← optional component overrides
* └── ...
*
* 2. Import and register here:
* import myTheme from "./my-awesome-theme";
* registerTheme(myTheme);
*
* That's it! The theme will appear in the theme picker.
*/
+263
View File
@@ -0,0 +1,263 @@
/**
* Theme Registry
* ==============
* Manages the set of installed themes and the currently active theme.
* Handles CSS variable injection, `<style>` element management, and
* lifecycle callbacks.
*
* This module is framework-agnostic (no React dependency). The React
* integration lives in `./context.tsx`.
*/
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
// ─── internal state ──────────────────────────────────────────────────────────
const installed = new Map<string, ThemePackage>();
let activeId: string | null = null;
let injectedStyleEl: HTMLStyleElement | null = null;
const STORAGE_KEY = "flvx:active-theme";
const MODE_KEY = "flvx:theme"; // backwards-compat with old use-theme
type ChangeListener = () => void;
const changeListeners = new Set<ChangeListener>();
function notify() {
changeListeners.forEach((fn) => fn());
}
// ─── public API ──────────────────────────────────────────────────────────────
/**
* Register a theme package. Call this for every theme you want available.
* Registering a theme with an existing id replaces the previous one.
*/
export function registerTheme(pkg: ThemePackage): void {
installed.set(pkg.id, pkg);
notify();
}
/** Unregister a theme by id. */
export function unregisterTheme(id: string): void {
if (activeId === id) deactivateTheme();
installed.delete(id);
notify();
}
/** Get all registered themes. */
export function getRegisteredThemes(): ThemePackage[] {
return Array.from(installed.values());
}
/** Get a specific theme by id. */
export function getTheme(id: string): ThemePackage | undefined {
return installed.get(id);
}
/** Get the id of the currently active theme (or null). */
export function getActiveThemeId(): string | null {
return activeId;
}
/** Get the currently active ThemePackage (or null). */
export function getActiveTheme(): ThemePackage | null {
return activeId ? installed.get(activeId) ?? null : null;
}
// ─── theme mode ──────────────────────────────────────────────────────────────
export type ThemeMode = "light" | "dark" | "system";
export function resolveSystemMode(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getSavedMode(): ThemeMode {
if (typeof window === "undefined") return "system";
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
}
export function saveMode(mode: ThemeMode): void {
localStorage.setItem(MODE_KEY, mode);
notify();
}
export function getEffectiveMode(): "light" | "dark" {
const mode = getSavedMode();
return mode === "system" ? resolveSystemMode() : mode;
}
// ─── activation ──────────────────────────────────────────────────────────────
/**
* Activate a theme by id. This:
* 1. Calls `onDeactivate` on the previous theme.
* 2. Injects CSS tokens onto `document.documentElement`.
* 3. Injects the theme's `css` string into a `<style>` element.
* 4. Calls `onActivate` on the new theme.
* 5. Persists the choice to localStorage.
*/
export function activateTheme(id: string): void {
const pkg = installed.get(id);
if (!pkg) {
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
return;
}
// Deactivate previous
deactivateTheme();
activeId = id;
// Inject tokens
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
// Inject custom CSS
if (pkg.css) {
injectedStyleEl = document.createElement("style");
injectedStyleEl.setAttribute("data-flvx-theme", id);
injectedStyleEl.textContent = pkg.css;
document.head.appendChild(injectedStyleEl);
}
// Update dark class
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
// Lifecycle
pkg.onActivate?.();
// Persist
localStorage.setItem(STORAGE_KEY, id);
notify();
}
/** Deactivate the current theme, reverting all overrides. */
export function deactivateTheme(): void {
const prev = activeId ? installed.get(activeId) : null;
prev?.onDeactivate?.();
// Remove injected tokens
clearInjectedTokens();
// Remove injected style element
if (injectedStyleEl) {
injectedStyleEl.remove();
injectedStyleEl = null;
}
activeId = null;
}
/** Re-apply the active theme (e.g. after mode changes). */
export function reapplyActiveTheme(): void {
if (activeId) {
const id = activeId;
// quick re-inject without full lifecycle
const pkg = installed.get(id);
if (!pkg) return;
clearInjectedTokens();
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
} else {
// No theme active — just set dark class based on mode
const mode = getEffectiveMode();
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
}
notify();
}
// ─── component resolution ────────────────────────────────────────────────────
/**
* Resolve a component: returns the themed override if present, otherwise
* returns the fallback (default implementation).
*/
export function resolveComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.components?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
/** Resolve a layout override. */
export function resolveLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const pkg = activeId ? installed.get(activeId) : null;
return pkg?.layouts?.[key] ?? fallback;
}
/** Resolve a page override. */
export function resolvePage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.pages?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
// ─── subscriber API (for React) ──────────────────────────────────────────────
export function subscribe(listener: ChangeListener): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
// ─── initialisation ──────────────────────────────────────────────────────────
/**
* Call once at app boot. Restores the previously active theme from
* localStorage (if the theme is registered).
*/
export function initThemeSystem(): void {
const savedId = localStorage.getItem(STORAGE_KEY);
if (savedId && installed.has(savedId)) {
activateTheme(savedId);
} else {
// Just apply mode
reapplyActiveTheme();
}
}
// ─── internal helpers ────────────────────────────────────────────────────────
const injectedVars: string[] = [];
function injectTokens(tokens: ThemeTokens): void {
const root = document.documentElement;
for (const [varName, value] of Object.entries(tokens)) {
if (value !== undefined) {
root.style.setProperty(varName, value);
injectedVars.push(varName);
}
}
}
function clearInjectedTokens(): void {
const root = document.documentElement;
for (const varName of injectedVars) {
root.style.removeProperty(varName);
}
injectedVars.length = 0;
}

Some files were not shown because too many files have changed in this diff Show More